Skip to content

Commit 19d1b8d

Browse files
committed
fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94f0cf0 commit 19d1b8d

2 files changed

Lines changed: 51 additions & 2 deletions

File tree

‎packages/runtime/src/action-execution.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,34 @@ export async function callData(deps: ActionExecutionDeps,
364364
}
365365

366366
if (action === 'update') {
367+
// [#23462] `expectedVersion` rides through to `updateData`, whose
368+
// version check refuses a stale update. This arm forwarded only
369+
// `{ object, id, data }`, so a token the `/data` domain read was
370+
// dropped here a second time. Present only when a caller sent one:
371+
// every other caller hands `{ object, id, data }` and reaches
372+
// `updateData` as before. The `delete` arm below does the same.
373+
const expectedVersion = params?.expectedVersion;
367374
if (protocol && typeof protocol.updateData === 'function') {
368-
return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
375+
return await protocol.updateData({
376+
object: params.object,
377+
id: params.id,
378+
data: params.data,
379+
...(expectedVersion !== undefined ? { expectedVersion } : {}),
380+
...(scopeId ? { environmentId: scopeId } : {}),
381+
context: executionContext,
382+
});
383+
}
384+
// [#23462] The fallback below updates by id and nothing else, so it
385+
// cannot judge a version. A caller who sent one is refused `501`
386+
// before anything is read, never updated unguarded: that is the very
387+
// drop the arm above just closed. The `delete` arm's fallback refuses
388+
// it the same way.
389+
if (expectedVersion !== undefined) {
390+
throw {
391+
statusCode: 501,
392+
message: 'This host has no protocol updateData, so it cannot honour expectedVersion or If-Match on an '
393+
+ 'update. Send the update without them, or update through a host whose protocol implements updateData.',
394+
};
369395
}
370396
if (ql && params.id && typeof ql.update === 'function') {
371397
let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));

‎packages/runtime/src/domains/data.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,31 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m
162162
// PATCH /data/:object/:id
163163
if (parts.length === 2 && m === 'PATCH') {
164164
const id = parts[1];
165+
// [#23462] The optimistic-concurrency token, read the way
166+
// `RestServer`'s twin of this route reads it: the body's
167+
// `expectedVersion`, else `If-Match`, and forwarded to `updateData`,
168+
// which refuses an update whose version moved (`409
169+
// CONCURRENT_UPDATE`). This branch read neither and handed the
170+
// whole body over as the patch, so through the `@objectstack/hono`
171+
// catch-all a stale `If-Match` update was APPLIED, and a body
172+
// `expectedVersion` reached the engine as a field it refused as
173+
// unknown. The key is stripped from the patch whatever its value,
174+
// and a token is forwarded only when truthy: the twin's own
175+
// statements, so both doors send `updateData` the same thing.
176+
const bodyVersion = (body && typeof body === 'object') ? body.expectedVersion : undefined;
177+
const expectedVersion = bodyVersion ?? requestIfMatch(_context.request?.headers);
178+
let data = body;
179+
if (body && typeof body === 'object' && 'expectedVersion' in body) {
180+
const { expectedVersion: _token, ...patch } = body;
181+
data = patch;
182+
}
165183
// Spec: returns UpdateDataResponse = { object, id, record }
166-
const result = await actionExec.callData(deps, _context, 'update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext);
184+
const result = await actionExec.callData(deps, _context, 'update', {
185+
object: objectName,
186+
id,
187+
data,
188+
...(expectedVersion ? { expectedVersion: String(expectedVersion) } : {}),
189+
}, _context.dataDriver, _context.environmentId, _context.executionContext);
167190
return { handled: true, response: deps.success(result) };
168191
}
169192

0 commit comments

Comments
 (0)