Skip to content

Commit 5fcc794

Browse files
fix(rest)!: POST /batch answers an update or delete op whose id names no row with the missing-id 400, not a 500 (#24820)
Fixes #24781 Clause-②: no (narrowing) ## What changed `POST /api/v1/batch` (the cross-object transactional batch, and its environment-scoped twin from the same registrar) checked up front that an `update` / `delete` op names a row. But it tested only for an absent or `null` id: `op.id == null && op.data?.id == null`. An id the engine does not take as one passed that check and reached `ql.update` / `ql.delete`. The engine's `Update requires an ID or options.multi=true` / `Delete requires an ID or options.multi=true` throw then answered as a server fault. The fix is in `packages/rest/src/batch-routes.ts`: - `batchOpRowAddress(op, data)` is the one reading of the row an op addresses. `update` reads `id`, else `data.id` (the arm's own `op.id ?? data?.id`). `delete` reads `id` alone, which is all its arm ever sent. The up-front check and both write arms read it, so the check can no longer pass an op that its arm then sends with no address. - `namesNoRow(action, address)` judges that address with the engine's own by-id predicates, `resolveEngineUpdateDispatch` / `resolveEngineDeleteDispatch` from `@objectstack/metadata-core`. These are the functions `ObjectQL.update` / `ObjectQL.delete` dispatch on, and `@objectstack/rest` already depends on that package. A by-id address is a truthy string or number. - `isBatchRef` is now the one spelling of the `{ $ref }` test, shared with the ref resolver. An update whose `data.id` references an earlier create is not judged up front; the resolver still refuses an unresolvable one with `BATCH_UNRESOLVED_REF`. The refusal's code and sentence are the ones a missing id already got: `400 VALIDATION_FAILED`, `Operation 'update' on 'OBJECT' requires an id` (or `'delete'`). ## Contract These are all the inputs whose answer changes. Before, the transaction opened, the engine threw and the batch rolled back, so nothing was written. Now the batch is refused before the transaction opens, and nothing is written. | op on cross-object `POST /batch` | before | after | |---|---|---| | `update`, `"id": ""` (the card) | `500 DATABASE_ERROR`, "Internal data error" | `400 VALIDATION_FAILED`, `Operation 'update' on 'OBJECT' requires an id` | | `update`, no `id`, `data.id` is `""`, `0`, `false`, `true`, `{}` or `[]` | `500 DATABASE_ERROR` | the same `400` | | `update`, `"id": ""` beside a real `data.id` (`id` is read first) | `500 DATABASE_ERROR` | the same `400` | | `delete`, `"id": ""` (the card), with or without a `data.id` | `500 INTERNAL_ERROR`, "Internal server error" | `400 VALIDATION_FAILED`, `Operation 'delete' on 'OBJECT' requires an id` | | `delete`, no `id`, the record's id only in `data.id` | `400 INVALID_FILTER`, the engine's filter guard ("Filter comparand at where.id is undefined …") | the same `400 VALIDATION_FAILED` "… requires an id" | | any of the above, where a later step used to answer first (the per-object API-exposure gate's `404` / `405`, or another op in the batch) | that later step's answer | this `400`. The id check runs before the exposure check and the transaction, as it already did for a missing id | These answers do not move, and the pins below hold them as controls: - A missing or `null` id gets the same `400`. - An update by `id` or by `data.id`, and a delete by `id`, write as before. - An update whose `data.id` is `{ "$ref": 0 }` still resolves to the row created by op 0. - An unknown id answers `404 RECORD_NOT_FOUND`. So does a whitespace id (`" "`), which the engine looks up like any other id. - `atomic: false` still answers `400 BATCH_NOT_ATOMIC` first, because that check comes before this one. With `atomic: true` and with `atomic` omitted, the rule is the same. The delete-by-`data.id` row is fixed in place under the bounded exemption. It is the same defect class (the up-front check passed an op that its arm sent with no address), in the same check, the same file and the same gate family. Leaving it out would mean the check knowingly accepts `data.id` for a delete while the arm ignores it. ## Which ids count as missing The rule is "refuse what can never address a record", judged by the engine. Measured with `resolveEngine*Dispatch` and with the `500`s above on `main`: - `""`, `0`, booleans, objects and arrays are never a by-id address, so they are refused. - A whitespace string is a by-id address. The engine and the driver look it up, and the answer is an honest `404`, so it is not refused here. - `null` in `data.id` was already refused. - `op.id` is `z.string().optional()` in the spec, so a non-string `id` is still refused by the schema (`Invalid batch request`) before this step. This is not id-format validation. ## Other doors that serve the operation - The per-object `POST /data/:object/batch`, `updateMany` and `deleteMany` have no REST-level up-front id check. The protocol's row check is `if (!record.id) throw rowRequiredIdError(…)`, a falsy test. Measured with the same real-engine harness: - `update` / `delete` rows with `"id": ""` answer a per-row `VALIDATION_FAILED`, "Record id is required for update" (or delete). - `deleteMany` with `ids: [""]` answers `400 VALIDATION_FAILED`. - A whitespace id answers a per-row `404`. These doors do not share the gap, so there is no shared helper. - `@objectstack/runtime`'s dispatcher has no `/batch` branch. `domains/data.ts` routes only `query`, and `http-dispatcher.ts` reports `transactionalBatch: false` for exactly that reason. There is no twin. ## Tests The new `packages/rest/src/rest-batch-blank-id.test.ts` runs a real engine (`ObjectQL` + sqlite `SqlDriver`), the real protocol and the real route, with `objectQLProvider` wired. It has: - 11 refusal rows. Each asserts the whole `{ status, body }` (status, `code` and sentence), that `engine.transaction` was never called, and that the seeded row is unchanged. - One row proving that an earlier `create` in the same batch is not written. - 8 controls, the ones listed above. Red and green: - On `main` `a43405d59`, the pin file gave **12 failed / 8 passed**. Update rows answered `500 DATABASE_ERROR`, delete `""` rows `500 INTERNAL_ERROR`, and delete-by-`data.id` `400 INVALID_FILTER`. - Ablation 1, with the fix committed: `scripts/ablation-replace.mjs` swapped the check back to `op.id == null && op.data?.id == null`. The landing was proven by the anchor count going 1 → 0 and the blob going `c2ad0e6dac7e` → `7be150e2b515`. Result: **12 failed / 8 passed**, the same rows. - Ablation 2: the `isBatchRef` carve-out deleted. Exactly the `$ref` control failed (`expected 400 to be 200`). - Both ablations were restored with blob == HEAD and `git diff HEAD` empty. - After the fix: pin file 20/20. `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2` on `0d4ea3e5b` (after merging `origin/main` `307ce4c4b`): **305 files passed, 6144 tests passed, 326 skipped**. `pnpm --filter @objectstack/rest typecheck` (tsc plus the test-layer check) exited 0. ## Gates (local, on `0d4ea3e5b`) - `node scripts/pm/dispatch-gates.mjs --commands` derived 67 commands for this diff against merge base `307ce4c4b`. All 67 ran and exited 0, with each exit code captured before any pipe. The `--ran` reconciliation reports 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint` (`eslint . --no-inline-config`), the family the derivation does not name: exit 0. - `test:repo` (the `repo` vitest project) was not run locally. The diff touches none of its five files, and CI's `Test Core` runs it. ## Changeset `@objectstack/rest`: **minor**, carrying this PR's narrowing declaration line and an ADR-0087 `not-required (no-migration-prescription)` disposition. The dispatch asked for `patch`. Measured on a throwaway commit object (no branch or tree change): `check-changeset-no-major.mjs` with this PR's declaration line and a `patch` grade on `@objectstack/rest` exits 1 ("clause-② declares `no (narrowing)` while no moved package is graded `minor` or above"). With `minor` it passes. A narrowing ships `minor` in the launch window. ## Acceptance notes - The engine's `Update requires an ID…` / `Delete requires…` throws still answer `500` wherever another caller reaches them, with `DATABASE_ERROR` and `INTERNAL_ERROR` disagreeing between the two verbs. That mapping is held off here: it is the door-parity region of #24768, which carries it. - The spec's `CrossObjectBatchOperationSchema.id` is `z.string().optional()` with no `.min(1)`, so `""` passes the schema and reaches this check. Tightening the schema would move the refusal to `Invalid batch request` with a different sentence. That is `packages/spec`, held off, and not a defect after this fix. Noted, not filed; no carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99e4eaa commit 5fcc794

3 files changed

Lines changed: 280 additions & 7 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: `POST /batch` answers an update or delete op whose id names no row with the missing-id `400`, not a `500` (#24781)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A refusal at the cross-object batch door of request shapes that never wrote anything: every one of them already failed, as a 500 (or, for a delete addressed only by data.id, a 400 INVALID_FILTER), and now fails up front with the 400 a missing id already got. No metadata key, spelling, export, type or stored shape moves, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the remedy is sending the record's id, stated below. -->
10+
11+
**BREAKING** accept-set narrowing on one REST door, shipped as `minor` under the launch-window convention for accept-set narrowings. No request that succeeded before is refused now.
12+
13+
The cross-object batch checked up front that an `update` / `delete` op names a row, but tested only for an absent or `null` id. An id the engine does not take as one reached the engine, and its `Update requires an ID…` / `Delete requires an ID…` throw answered as a server fault. A client's retry policy keyed on `5xx` then retried a request that can never succeed. The check now judges the id the op's write arm actually sends, with the engine's own by-id rule (a truthy string or number).
14+
15+
- FROM: `update` with `"id": ""`, or with no `id` and a `data.id` of `""`, `0`, `true`, `false`, an object or an array, answered `500 DATABASE_ERROR`. An `"id": ""` beside a real `data.id` did too, because `id` is read first.
16+
- FROM: `delete` with `"id": ""` answered `500 INTERNAL_ERROR`.
17+
- FROM: `delete` with no `id` and the record's id in `data.id` answered `400 INVALID_FILTER`, with a message about a `where.id` the caller never wrote. The delete arm never read `data.id`; it is the create/update payload.
18+
- TO: each of these answers `400 VALIDATION_FAILED` with `Operation 'update' on 'OBJECT' requires an id` (or `'delete'`), the answer the same op with no id already got. The batch is refused before the transaction opens, and nothing is written.
19+
- Fix: send the record's id in the op's `id` (an update may carry it in `data.id` instead).
20+
21+
This step runs before the per-object API-exposure check, as it did for a missing id. So for such an op, this `400` now also replaces whatever a later step used to answer first, such as the `404` / `405` for an API-disabled object or method.
22+
23+
**Unchanged.** A missing or `null` id answers the same `400`. A real id updates and deletes as before, by `id` or (update only) by `data.id`. An update whose `data.id` is a `{ $ref }` to an earlier create still resolves. A whitespace id is an id the engine looks up: it answers `404 RECORD_NOT_FOUND`, like any unknown id. The per-object doors (`/data/:object/batch`, `updateMany`, `deleteMany`) already refused a blank id per row and are not changed.

‎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 } });
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)