Skip to content

Commit 38f53a0

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): updateMany classifies an id-less row as a caller error, matching batchData (#5100) (#5200)
runUpdateManyLoop lacked the !record.id guard #4793 gave batchData's update branch: the same malformed row answered VALIDATION_FAILED/400 on one by-id face and RECORD_NOT_FOUND/404 ('Record undefined not found') on the other, with the unguarded path handing { id: undefined } to the probe and the write — a reading each driver decides for itself. The guard fires before any engine round-trip, both faces now give one classification (#4620), pinned by a cross-face parity test. record.data handling aligned to the batch branch's || {} in passing. Dormant over REST (UpdateManyRecordSchema requires id, #3939); the change is observable only to in-process protocol callers. Proven red-first: both new cases fail unguarded (RECORD_NOT_FOUND where VALIDATION_FAILED is asserted). After: metadata-protocol 348 tests, typecheck across 82 dependent tasks all green. Claude-Session: https://claude.ai/code/session_01BotUP49pqhvqGY393n2HfU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c2f7dd commit 38f53a0

3 files changed

Lines changed: 79 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `updateMany` classifies an id-less row as a caller error, matching `batchData`'s update branch (#5100)
6+
7+
`runUpdateManyLoop` lacked the `!record.id` guard #4793 gave `runBatchDataLoop`'s
8+
update branch, so the two by-id update faces classified the same malformed row
9+
differently: `VALIDATION_FAILED`/400 on batch, but on `updateMany` the row fell
10+
through to the #5088 existence probe as `{ id: undefined }` and came back
11+
`RECORD_NOT_FOUND`/404 with `undefined` interpolated into the message — a
12+
request-shape error reported as a data-state one, with the row's fate left to
13+
each driver's undefined-where-key handling.
14+
15+
Not reachable over REST (`UpdateManyRecordSchema` requires `id`, #3939) — the
16+
change is observable only to in-process callers of the protocol method, whose
17+
id-less rows now answer `VALIDATION_FAILED`/400 (`Record id is required for
18+
update`) before any engine round-trip, identically on both faces (#4620: one
19+
classification per file, enforced by a cross-face parity test). `record.data`
20+
handling is aligned to the batch branch's `record.data || {}` in the same
21+
change.

packages/metadata-protocol/src/protocol.bulk-record-not-found.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,52 @@ describe('[#5088] batchData delete — the driver`s return decides, as in delete
421421
});
422422
});
423423

424+
describe('[#5100] an id-less row is a CALLER error on both by-id update faces', () => {
425+
it('updateMany: VALIDATION_FAILED/400 before any engine read or write', async () => {
426+
const t = makeStoreEngine();
427+
const p = new ObjectStackProtocolImplementation(t.engine);
428+
429+
const res: any = await p.updateManyData({
430+
object: 'showcase_task',
431+
records: [{ data: { progress: 1 } }],
432+
options: { continueOnError: true },
433+
} as any);
434+
435+
expect(res.succeeded).toBe(0);
436+
expect(res.failed).toBe(1);
437+
expect(res.results[0].errors?.[0]?.code).toBe('VALIDATION_FAILED');
438+
expect(res.results[0].errors?.[0]?.httpStatus).toBe(400);
439+
expect(res.results[0].errors?.[0]?.message).toBe('Record id is required for update');
440+
// A missing id is a request-shape error, not a data-state one — and it
441+
// must fail BEFORE any engine round-trip: unguarded, the row reached
442+
// the #5088 probe as `{ id: undefined }`, whose reading is up to the
443+
// driver's undefined-where-key handling, and came back as a 404 with
444+
// `undefined` interpolated into the message.
445+
expect(res.results[0].errors?.[0]?.message).not.toContain('not found');
446+
expect(t.findOne).not.toHaveBeenCalled();
447+
expect(t.update).not.toHaveBeenCalled();
448+
});
449+
450+
it('the two by-id update faces give ONE classification for the same malformed row (#4620)', async () => {
451+
const t = makeStoreEngine();
452+
const p = new ObjectStackProtocolImplementation(t.engine);
453+
454+
const many: any = await p.updateManyData({
455+
object: 'showcase_task',
456+
records: [{ data: { progress: 1 } }],
457+
} as any);
458+
const batch: any = await p.batchData({
459+
object: 'showcase_task',
460+
request: { operation: 'update', records: [{ data: { progress: 1 } }] },
461+
} as any);
462+
463+
expect(many.results[0].errors[0].code).toBe(batch.results[0].errors[0].code);
464+
expect(many.results[0].errors[0].message).toBe(batch.results[0].errors[0].message);
465+
expect(many.results[0].errors[0].httpStatus).toBe(batch.results[0].errors[0].httpStatus);
466+
expect(batch.results[0].errors[0].code).toBe('VALIDATION_FAILED');
467+
});
468+
});
469+
424470
describe('[#5088] the three by-id write faces answer the SAME thing', () => {
425471
it('single-record PATCH, updateMany and batchData produce one message for one missing id', async () => {
426472
const t = makeStoreEngine();

packages/metadata-protocol/src/protocol.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5935,6 +5935,17 @@ export class ObjectStackProtocolImplementation implements
59355935
// (#2948) / `readonlyWhen` (#3042) strips that single-write now
59365936
// surfaces (#3431) happened silently here. Collect per row.
59375937
//
5938+
// [#5100] Same guard as `runBatchDataLoop`'s update branch
5939+
// (#4793), same classification: an id-less row is a CALLER
5940+
// error — VALIDATION_FAILED/400 — not a data-state one. The
5941+
// REST entrance already rejects it (`UpdateManyRecordSchema`
5942+
// requires `id`, #3939), but that invariant lives two packages
5943+
// away; unguarded, an in-process caller's malformed row
5944+
// reached the probe and the write as `{ id: undefined }`,
5945+
// whose reading is up to each driver's undefined-where-key
5946+
// handling — at best a 404 with `undefined` interpolated into
5947+
// the message, at worst a where-clause with no id at all.
5948+
if (!record.id) throw rowRequiredIdError('update');
59385949
// [#5088] Third gap, the same shape: no existence gate. A row
59395950
// naming no record went straight into `engine.update`, so the
59405951
// hook pipeline ran over a payload-only record and the row came
@@ -5945,7 +5956,7 @@ export class ObjectStackProtocolImplementation implements
59455956
const dropped: DroppedFieldsEvent[] = [];
59465957
const opts: any = { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } };
59475958
if (context !== undefined) opts.context = context;
5948-
const updated = await this.engine.update(object, record.data, opts);
5959+
const updated = await this.engine.update(object, record.data || {}, opts);
59495960
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
59505961
succeeded++;
59515962
} catch (err: any) {

0 commit comments

Comments
 (0)