Skip to content

Commit 027961d

Browse files
committed
fix(rest): POST /batch refuses a blank update/delete id with the missing-id 400
The cross-object batch's up-front id check read `op.id == null && op.data?.id == null`, so an address the engine does not take as an id (the empty string, 0, a boolean, an object or an array in data.id) passed it and the engine's "requires an ID" throw answered 500. A delete naming its id only in data.id passed it too, though the delete arm never read data.id. The check now judges the address each arm hands the engine (batchOpRowAddress), with the engine's own by-id predicates, and both arms read that same address. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a43405d commit 027961d

2 files changed

Lines changed: 257 additions & 7 deletions

File tree

‎packages/rest/src/batch-routes.ts‎

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data';
5353
// The write-response rules (credential-class mask, `internal` omit) for
5454
// the cross-object batch's direct-engine update arm.
5555
import { omitInternalFieldsFromWriteResponse } from '@objectstack/core';
56+
// The engine's own by-id predicates, which `ObjectQL.update` / `.delete`
57+
// dispatch on — the cross-object batch's up-front id check asks them.
58+
import { resolveEngineDeleteDispatch, resolveEngineUpdateDispatch } from '@objectstack/metadata-core';
5659
import { handleRouteError } from './error-response.js';
5760
import type { ServerScopedDataRequest } from './data-action-routes.js';
5861
import {
@@ -81,6 +84,64 @@ export interface BatchRouteHost {
8184
loadObjectItems: RestServer['loadObjectItems'];
8285
}
8386

87+
/** One operation of the cross-object batch, as the spec contract parses it. */
88+
type CrossObjectBatchOp = {
89+
object: string;
90+
action: 'create' | 'update' | 'delete';
91+
id?: string;
92+
data?: Record<string, any>;
93+
};
94+
95+
/**
96+
* A field value of the form `{ $ref: <earlier op index> }`: an intra-batch
97+
* reference the cross-object batch resolves, inside the transaction, to that
98+
* op's created id. One spelling for the resolver and for the up-front id check,
99+
* so the check never judges a value the resolver would have replaced.
100+
*/
101+
function isBatchRef(value: unknown): value is { $ref: unknown } {
102+
return Boolean(value) && typeof value === 'object' && '$ref' in (value as object);
103+
}
104+
105+
/**
106+
* The row an `update` / `delete` op of the cross-object batch addresses. The
107+
* up-front refusal and both write arms read it HERE, so the refusal cannot pass
108+
* an op that its arm then hands to the engine without an id.
109+
*
110+
* - `update`: `id`, else the payload's `data.id`. An `id` key that is present
111+
* wins even when it names no row: it is refused, and the payload's id does
112+
* not silently replace it.
113+
* - `delete`: `id` alone. The delete arm addresses `where: { id }` and never
114+
* read `data.id`, which the spec declares as the create/update payload. The
115+
* up-front check used to accept `data.id` for a delete anyway, and the op
116+
* then reached the engine with no address at all.
117+
*/
118+
function batchOpRowAddress(op: CrossObjectBatchOp, data: Record<string, any> | undefined): unknown {
119+
return op.action === 'delete' ? op.id : (op.id ?? data?.id);
120+
}
121+
122+
/**
123+
* Does this address name no row the engine could write? Such an op is refused
124+
* up front with the answer a missing id gets. Before, it reached `ql.update` /
125+
* `ql.delete`, whose `… requires an ID or options.multi=true` throw answered as
126+
* a 500.
127+
*
128+
* Judged by the engine's OWN by-id predicates (`resolveEngineUpdateDispatch` /
129+
* `resolveEngineDeleteDispatch`, the functions `ObjectQL.update` and
130+
* `ObjectQL.delete` dispatch on), never by a mirrored `if`. A by-id address is
131+
* a TRUTHY scalar. So `undefined`, `null`, `''`, `0`, a boolean, an object and
132+
* an array name no row. A whitespace string does name one: the engine looks it
133+
* up and answers 404 when no row has it. ⛔ This is not an id FORMAT check.
134+
*
135+
* A `{ $ref }` is not judged here. It is resolved inside the transaction, and
136+
* a reference to no earlier create is refused there (`BATCH_UNRESOLVED_REF`).
137+
*/
138+
function namesNoRow(action: 'update' | 'delete', address: unknown): boolean {
139+
if (isBatchRef(address)) return false;
140+
return action === 'delete'
141+
? resolveEngineDeleteDispatch({ where: { id: address } }).kind !== 'by-id'
142+
: resolveEngineUpdateDispatch({ id: address }, undefined).kind !== 'by-id';
143+
}
144+
84145
/**
85146
* Register batch operation endpoints — `RestServer#registerBatchEndpoints`'
86147
* body, called by that method with the server as `this`. The method's own
@@ -185,7 +246,7 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
185246
res.status(400).json({ error: 'Invalid batch request', code: 'VALIDATION_FAILED', issues: parsed.error?.issues });
186247
return;
187248
}
188-
const ops: Array<{ object: string; action: 'create' | 'update' | 'delete'; id?: string; data?: Record<string, any> }> = parsed.data.operations;
249+
const ops: CrossObjectBatchOp[] = parsed.data.operations;
189250
// All-or-nothing by construction: refuse a request that asks for
190251
// non-atomic semantics rather than silently applying atomically
191252
// (honest contract). Per-object partial batches use the
@@ -201,9 +262,11 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
201262
if (this.enforceBatchSize(res, ops.length, maxBatch)) return;
202263

203264
// update/delete need a target id — the schema can't express this
204-
// conditionally, so surface it as a 400 up front.
265+
// conditionally, so surface it as a 400 up front. "Missing" is
266+
// the address the op's arm would hand the engine naming no row
267+
// (`namesNoRow`): a blank id gets the answer an absent one does.
205268
for (const op of ops) {
206-
if ((op.action === 'update' || op.action === 'delete') && op.id == null && op.data?.id == null) {
269+
if ((op.action === 'update' || op.action === 'delete') && namesNoRow(op.action, batchOpRowAddress(op, op.data))) {
207270
res.status(400).json({ error: `Operation '${op.action}' on '${op.object}' requires an id`, code: 'VALIDATION_FAILED' });
208271
return;
209272
}
@@ -240,8 +303,8 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
240303
if (!data || typeof data !== 'object') return data;
241304
const result: any = Array.isArray(data) ? [] : {};
242305
for (const [k, v] of Object.entries(data)) {
243-
if (v && typeof v === 'object' && '$ref' in (v as any)) {
244-
const idx = (v as any).$ref;
306+
if (isBatchRef(v)) {
307+
const idx = v.$ref;
245308
const ref = typeof idx === 'number' ? out[idx] : undefined;
246309
const refId = ref && (ref.id ?? ref._id);
247310
if (refId == null) {
@@ -309,7 +372,7 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
309372
// and `readonlyWhen` (#3042) on its own update path,
310373
// and reports them through this listener.
311374
const onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push({ ...e, index }); };
312-
const id = op.id ?? data?.id;
375+
const id = batchOpRowAddress(op, data);
313376
const updated = await ql.update(op.object, { ...data, id }, { context: trxCtx, onFieldsDropped });
314377
// [#7823] …but the RESPONSE half moved to the ingress
315378
// (A-prime ruling, 2026-08-13): the engine keeps its
@@ -330,7 +393,7 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
330393
out.push(updated);
331394
}
332395
} else { // 'delete'
333-
out.push(await ql.delete(op.object, { where: { id: op.id }, context: trxCtx }));
396+
out.push(await ql.delete(op.object, { where: { id: batchOpRowAddress(op, data) }, context: trxCtx }));
334397
}
335398
}
336399
return out;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /api/v1/batch` — an `update` / `delete` op whose row address names no
5+
* row is refused up front with the missing-id answer, on a real engine
6+
* (`ObjectQL` + sqlite `SqlDriver`), the real protocol and the real
7+
* `RestServer` route, with `objectQLProvider` wired so the cross-object door is
8+
* reachable rather than answering 501.
9+
*
10+
* The door's up-front check read `op.id == null && op.data?.id == null`, so an
11+
* address the engine does not take as an id — the empty string, `0`, a
12+
* non-scalar — passed it, reached `ql.update` / `ql.delete`, and the engine's
13+
* `Update requires an ID…` / `Delete requires an ID…` throw answered as a
14+
* server fault. And the check accepted `data.id` for a `delete`, whose write
15+
* arm never read it. Both are now judged once: the address each arm hands the
16+
* engine (`batchOpRowAddress`), against the engine's own by-id predicate.
17+
*
18+
* Measured on `origin/main` a43405d59 with this harness, before the fix: every
19+
* REFUSED row below answered `500` — `DATABASE_ERROR` on an update arm,
20+
* `INTERNAL_ERROR` on a delete arm — and wrote nothing (the transaction rolled
21+
* the batch back). They now answer `400 VALIDATION_FAILED` with the sentence a
22+
* missing id gets, before the transaction opens.
23+
*
24+
* Controls: a missing id answers as before; a real id updates and deletes as
25+
* before (by `id`, and an update by `data.id`); an update whose `data.id` is a
26+
* `{ $ref }` to an earlier create still resolves; a whitespace id is an id the
27+
* engine looks up, so it answers what an unknown id answers (`404`), not this
28+
* refusal.
29+
*/
30+
31+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
32+
import { ObjectQL } from '@objectstack/objectql';
33+
import { SqlDriver } from '@objectstack/driver-sql';
34+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
35+
import { RestServer } from './rest-server';
36+
37+
const OBJ = {
38+
name: 'blank_id_task', label: 'Task', systemFields: false,
39+
fields: {
40+
id: { name: 'id', type: 'text' as const, primaryKey: true },
41+
title: { name: 'title', type: 'text' as const },
42+
},
43+
};
44+
45+
const liveEngines: ObjectQL[] = [];
46+
afterEach(async () => {
47+
while (liveEngines.length) {
48+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
49+
}
50+
});
51+
52+
function createMockServer() {
53+
const noop = () => {};
54+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
55+
}
56+
57+
function makeRes() {
58+
const res: any = {
59+
write: () => true, end: () => {},
60+
header: () => res,
61+
status: (code: number) => { res._status = code; return res; },
62+
json: (body: any) => { res._json = body; return res; },
63+
};
64+
return res;
65+
}
66+
67+
async function boot() {
68+
const engine = new ObjectQL();
69+
liveEngines.push(engine);
70+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
71+
engine.registerDriver(driver, true);
72+
await engine.init();
73+
engine.registry.registerObject(OBJ as any);
74+
await engine.syncSchemas();
75+
await engine.insert('blank_id_task', { id: 'row-1', title: 'Kept' });
76+
77+
const protocol = new ObjectStackProtocolImplementation(engine as any);
78+
const rest = new RestServer(
79+
createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
80+
undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider
81+
(async () => engine) as any, // objectQLProvider (positional arg #8)
82+
);
83+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
84+
rest.registerRoutes();
85+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/batch');
86+
expect(route, 'POST /api/v1/batch').toBeDefined();
87+
const transaction = vi.spyOn(engine, 'transaction');
88+
const batch = async (operations: unknown[], extra: Record<string, unknown> = {}) => {
89+
const res = makeRes();
90+
await route!.handler({ method: 'POST', params: {}, query: {}, headers: {}, body: { operations, ...extra } } as any, res);
91+
return { status: res._status ?? 200, body: res._json };
92+
};
93+
const row = (id: string) => engine.findOne('blank_id_task', { where: { id } } as any);
94+
return { batch, row, transaction };
95+
}
96+
97+
const O = 'blank_id_task';
98+
const missingId = (action: 'update' | 'delete') => ({
99+
error: `Operation '${action}' on '${O}' requires an id`,
100+
code: 'VALIDATION_FAILED',
101+
});
102+
103+
/** Every op here names no row the engine could address; the arm is the op's own. */
104+
const REFUSED: ReadonlyArray<readonly [string, 'update' | 'delete', Record<string, unknown>]> = [
105+
['update, id ""', 'update', { id: '', data: { title: 'Changed' } }],
106+
['update, data.id ""', 'update', { data: { id: '', title: 'Changed' } }],
107+
['update, id "" beside a real data.id (the arm reads id first)', 'update', { id: '', data: { id: 'row-1', title: 'Changed' } }],
108+
['update, data.id 0', 'update', { data: { id: 0, title: 'Changed' } }],
109+
['update, data.id false', 'update', { data: { id: false, title: 'Changed' } }],
110+
['update, data.id true', 'update', { data: { id: true, title: 'Changed' } }],
111+
['update, data.id {}', 'update', { data: { id: {}, title: 'Changed' } }],
112+
['update, data.id []', 'update', { data: { id: [], title: 'Changed' } }],
113+
['delete, id ""', 'delete', { id: '' }],
114+
['delete, id "" beside a real data.id', 'delete', { id: '', data: { id: 'row-1' } }],
115+
['delete, a real id in data.id only (the delete arm never read data.id)', 'delete', { data: { id: 'row-1' } }],
116+
];
117+
118+
describe('POST /api/v1/batch: an update or delete op that names no row answers the missing-id 400', () => {
119+
let ctx: Awaited<ReturnType<typeof boot>>;
120+
beforeEach(async () => { ctx = await boot(); });
121+
122+
it.each(REFUSED)('%s → 400 VALIDATION_FAILED, before the transaction opens, nothing written', async (_l, action, op) => {
123+
const res = await ctx.batch([{ object: O, action, ...op }], { atomic: true });
124+
expect({ status: res.status, body: res.body }).toEqual({ status: 400, body: missingId(action) });
125+
expect(ctx.transaction).not.toHaveBeenCalled();
126+
expect(await ctx.row('row-1')).toMatchObject({ id: 'row-1', title: 'Kept' });
127+
});
128+
129+
it('refuses the whole batch up front: an earlier create in it is never written', async () => {
130+
const res = await ctx.batch([
131+
{ object: O, action: 'create', data: { id: 'row-2', title: 'New' } },
132+
{ object: O, action: 'update', id: '', data: { title: 'Changed' } },
133+
]);
134+
expect({ status: res.status, body: res.body }).toEqual({ status: 400, body: missingId('update') });
135+
expect(ctx.transaction).not.toHaveBeenCalled();
136+
expect(await ctx.row('row-2')).toBeFalsy();
137+
});
138+
139+
// ── controls: answers that do not move ────────────────────────────────────
140+
141+
it.each([
142+
['update', { data: { title: 'Changed' } }],
143+
['delete', {}],
144+
] as const)('control: %s with no id at all answers the same 400', async (action, op) => {
145+
const res = await ctx.batch([{ object: O, action, ...op }]);
146+
expect({ status: res.status, body: res.body }).toEqual({ status: 400, body: missingId(action) });
147+
expect(await ctx.row('row-1')).toMatchObject({ title: 'Kept' });
148+
});
149+
150+
it('control: an update by id writes the row', async () => {
151+
const res = await ctx.batch([{ object: O, action: 'update', id: 'row-1', data: { title: 'By id' } }]);
152+
expect(res.status).toBe(200);
153+
expect(await ctx.row('row-1')).toMatchObject({ title: 'By id' });
154+
});
155+
156+
it('control: an update by data.id writes the row', async () => {
157+
const res = await ctx.batch([{ object: O, action: 'update', data: { id: 'row-1', title: 'By data.id' } }]);
158+
expect(res.status).toBe(200);
159+
expect(await ctx.row('row-1')).toMatchObject({ title: 'By data.id' });
160+
});
161+
162+
it('control: an update whose data.id is a $ref to an earlier create still resolves', async () => {
163+
const res = await ctx.batch([
164+
{ object: O, action: 'create', data: { id: 'row-3', title: 'Created' } },
165+
{ object: O, action: 'update', data: { id: { $ref: 0 }, title: 'Then updated' } },
166+
]);
167+
expect(res.status).toBe(200);
168+
expect(await ctx.row('row-3')).toMatchObject({ title: 'Then updated' });
169+
});
170+
171+
it('control: a delete by id deletes the row', async () => {
172+
const res = await ctx.batch([{ object: O, action: 'delete', id: 'row-1' }]);
173+
expect(res.status).toBe(200);
174+
expect(await ctx.row('row-1')).toBeFalsy();
175+
});
176+
177+
it.each([
178+
['an unknown id', 'nope'],
179+
['a whitespace id (an id the engine looks up)', ' '],
180+
])('control: update and delete with %s answer 404 RECORD_NOT_FOUND, not this refusal', async (_l, id) => {
181+
for (const op of [{ action: 'update', id, data: { title: 'Changed' } }, { action: 'delete', id }]) {
182+
const res = await ctx.batch([{ object: O, ...op }]);
183+
expect([op.action, res.status, res.body?.code]).toEqual([op.action, 404, 'RECORD_NOT_FOUND']);
184+
}
185+
expect(await ctx.row('row-1')).toMatchObject({ title: 'Kept' });
186+
});
187+
});

0 commit comments

Comments
 (0)