Skip to content

Commit de96cf4

Browse files
fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution (#13917)
* fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution `bulkUpdate` resolved each id with a loose `==` (matching `update`, one method up) but built its untouched-row set `settled` from the CALLER's ids with a strict `Set.has`. `IDataDriver.bulkUpdate` declares `id: string | number`, so a caller may name a stored `1` as `'1'` — and then the two lookups disagreed: `findIndex` resolved the row and updated it, while `settled` still carried that row's PRE-image. The row was represented twice in the projected set handed to `assertUnique` — once with the value it was vacating, once with the value it was taking — so a batch that merely HANDS a unique value from one row to another was refused with a false `UNIQUE_VIOLATION` / 409. `exceptId` does not help, since it only excludes the row currently being checked, never a sibling row of the same batch. Resolve every id to its table index first, then derive the touched set from the RESOLVED rows' own ids. Both lookups now read the same stored value and cannot drift apart — the property `updateMany` gets for free by drawing its `targetIds` from table rows. The loose resolution is deliberately preserved: narrowing it to `===` would silently change which ids resolve at all, well beyond this defect. `bulkDelete` needs no change, and this is checked rather than assumed: it has exactly ONE id comparison, and dedups on the RESOLVED table index rather than on caller input, so a mixed-type or repeated id collapses to one index by construction. Keying that set on caller ids instead would splice one index twice and take a neighbouring row with it — pinned by test. Regression test uses mixed id types, alongside a positive control with consistent id types so it cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(driver-memory): cite the issue this fix closes, not the PR that introduced the defect Neighbouring comments in this file cite the ISSUE (`[#13435]`, `[#13197]`, `[#13340]`); this one cited #13875, which is the PR that introduced the defect. Comment text only — no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * test(driver-memory): cite the issue the new block pins, matching the rest of the file Comment and describe-title text only — no assertion changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cce0aa9 commit de96cf4

3 files changed

Lines changed: 157 additions & 3 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
fix(driver-memory): `bulkUpdate`'s touched-row set now agrees with its own id resolution, so a mixed id-type batch is no longer false-refused (#13911)
6+
7+
`IDataDriver.bulkUpdate` declares `id: string | number`, and this driver
8+
resolves an id to a row with a loose comparison — the way `update` and
9+
`delete` always have — so naming a stored `1` as `'1'` finds the same row.
10+
The all-or-nothing rework shipped one release earlier then built its
11+
untouched-row set from the *caller's* ids using strict `Set` membership, so
12+
for a mixed-type id the two lookups disagreed: the row was resolved and
13+
updated, yet also stayed in the untouched set carrying its **pre-image**. It
14+
faced the uniqueness check twice — once with the value it was vacating, once
15+
with the value it was taking — and a batch that merely HANDS a unique value
16+
from one row to another was refused with a false `UNIQUE_VIOLATION` / 409.
17+
18+
`bulkUpdate` now resolves every id to its table index first and derives the
19+
touched set from the *resolved rows' own ids*, so both lookups read the same
20+
stored value and cannot drift apart — the property the sibling `updateMany`
21+
gets for free by drawing its target ids from table rows. The loose resolution
22+
is deliberately preserved: tightening it would silently change which ids
23+
resolve at all, a far wider behaviour change than this defect.
24+
25+
A genuine collision is still refused, and the stored id keeps its own type —
26+
naming a row with a differently-typed id does not restamp it. `bulkDelete`
27+
needed no change: it has exactly one id comparison and dedups on the resolved
28+
table index rather than on caller input, so a mixed-type or repeated id
29+
collapses to a single index by construction.

packages/drivers/driver-memory/src/memory-bulk-update-delete-atomicity.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,109 @@ describe('[#13435] non-regression — updateMany and bulkCreate still behave as
328328
expect(await driver.count('doc')).toBe(3);
329329
});
330330
});
331+
332+
/**
333+
* [#13911] The batch's two id lookups must AGREE.
334+
*
335+
* `IDataDriver.bulkUpdate` declares `id: string | number`, so a caller may
336+
* legitimately name a row with an id whose JS type differs from the stored
337+
* row's — and `update`/`bulkUpdate` resolve ids with a LOOSE `==` precisely so
338+
* that `'1'` still finds stored `1`. The first cut of #13435 then built its
339+
* untouched-row set (`settled`) from the CALLER's ids with a STRICT `Set.has`,
340+
* so for a mixed-type id the two disagreed: `findIndex` resolved the row (it
341+
* got updated) while `settled` still carried that row's PRE-image. The row sat
342+
* in the projected check set twice — once stale, once pending — and a batch
343+
* that merely MOVES a unique value between rows was refused with a false
344+
* `UNIQUE_VIOLATION`.
345+
*
346+
* The sibling `updateMany` never had this gap: its `targetIds` come from table
347+
* ROWS and its `findIndex` is strict `===`, so both sides agree by
348+
* construction. The fix restores that property here the other way round —
349+
* keeping the loose resolution (narrowing it would silently change which ids
350+
* resolve at all) and drawing the touched set from the RESOLVED rows' own ids.
351+
*
352+
* ⛔ The discriminating fact is that a legitimate batch SUCCEEDS. A test that
353+
* only asserted "a collision still refuses" would pass against the defect.
354+
*/
355+
describe('[#13911] caller id TYPE never changes the outcome of a batch', () => {
356+
let driver: InMemoryDriver;
357+
358+
/** Numeric stored ids — the caller may still name them as strings. */
359+
beforeEach(async () => {
360+
driver = new InMemoryDriver();
361+
await driver.syncSchema('doc', DOC_SCHEMA);
362+
await driver.create('doc', { id: 1, doc_no: 'D-0001', title: 'One' });
363+
await driver.create('doc', { id: 2, doc_no: 'D-0002', title: 'Two' });
364+
});
365+
366+
it('POSITIVE CONTROL: the same hand-off with CONSISTENT id types succeeds', async () => {
367+
// Row 1 vacates D-0001; row 2 takes it. Nothing about this batch is
368+
// unusual — it is here so the mixed-type case below cannot pass vacuously.
369+
const out = await driver.bulkUpdate('doc', [
370+
{ id: 1, data: { doc_no: 'D-0900' } },
371+
{ id: 2, data: { doc_no: 'D-0001' } },
372+
]);
373+
374+
expect(out).toHaveLength(2);
375+
const rows = await snapshot(driver, 'doc');
376+
expect(rows.map((r: any) => [r.id, r.doc_no])).toEqual([
377+
[1, 'D-0900'],
378+
[2, 'D-0001'],
379+
]);
380+
});
381+
382+
it('a STRING id naming a NUMERIC row still hands a unique value over cleanly', async () => {
383+
// Identical to the control except the first id is a string. It resolves
384+
// (loose `==`), so row 1 really does vacate D-0001 — and row 2 taking it
385+
// must therefore NOT collide. Against the defect this threw a false
386+
// UNIQUE_VIOLATION, because row 1's stale pre-image stayed in `settled`.
387+
const out = await driver.bulkUpdate('doc', [
388+
{ id: '1', data: { doc_no: 'D-0900' } },
389+
{ id: 2, data: { doc_no: 'D-0001' } },
390+
]);
391+
392+
expect(out).toHaveLength(2);
393+
const rows = await snapshot(driver, 'doc');
394+
expect(rows.map((r: any) => [r.id, r.doc_no])).toEqual([
395+
[1, 'D-0900'],
396+
[2, 'D-0001'],
397+
]);
398+
});
399+
400+
it('the stored id KEEPS its own type — a string id in the batch does not restamp it', async () => {
401+
await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
402+
403+
const row: any = (await driver.find('doc', { where: { id: 1 } }))[0];
404+
expect(row.id).toBe(1);
405+
expect(row.title).toBe('Renamed');
406+
});
407+
408+
it('a REAL collision is still refused when the id types are mixed', async () => {
409+
// The fix must not turn the check off: row 2 keeps D-0002, so row 1 taking
410+
// it is a genuine violation however the caller spelled row 1's id.
411+
const before = await snapshot(driver, 'doc');
412+
413+
const err = await refusalOf(() => driver.bulkUpdate('doc', [{ id: '1', data: { doc_no: 'D-0002' } }]));
414+
415+
expect(err.code).toBe('UNIQUE_VIOLATION');
416+
expect(err.status).toBe(409);
417+
expect(await snapshot(driver, 'doc')).toEqual(before);
418+
});
419+
420+
it('bulkDelete: a mixed-type id removes exactly its own row', async () => {
421+
await driver.bulkDelete('doc', ['1']);
422+
423+
const rows = await snapshot(driver, 'doc');
424+
expect(rows.map((r: any) => r.id)).toEqual([2]);
425+
});
426+
427+
it('bulkDelete: the SAME row named twice in two id types is removed once, and only it', async () => {
428+
// `bulkDelete` dedups on the RESOLVED table index, not on the caller's id.
429+
// Keying the set on caller input instead would make '1' and 1 two entries
430+
// and splice index 0 twice — taking row 2 with it.
431+
await driver.bulkDelete('doc', ['1', 1]);
432+
433+
const rows = await snapshot(driver, 'doc');
434+
expect(rows.map((r: any) => [r.id, r.doc_no])).toEqual([[2, 'D-0002']]);
435+
});
436+
});

packages/drivers/driver-memory/src/memory-driver.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -907,14 +907,33 @@ export class InMemoryDriver implements IDataDriver {
907907
this.logger.debug('BulkUpdate operation', { object, count: updates.length });
908908

909909
const table = this.getTable(object);
910-
const touchedIds = new Set(updates.map((u) => u.id));
910+
911+
// [#13911] Resolve every id to its table row FIRST, then draw the touched
912+
// set from the RESOLVED rows' OWN ids — never from caller input. Ids are
913+
// resolved with a loose `==` (matching `update`, one method up), but a
914+
// `Set` membership test is always strict, so a caller naming a stored `1`
915+
// as `'1'` — which `IDataDriver.bulkUpdate` explicitly allows, `id` being
916+
// `string | number` — used to satisfy the resolving lookup while failing
917+
// the `settled` one. That row was then updated AND left in `settled`
918+
// carrying its PRE-image, so it faced the uniqueness check twice and a
919+
// batch merely HANDING a unique value from one row to another was refused
920+
// with a false `UNIQUE_VIOLATION`. Both lookups now read the same stored
921+
// value, so they cannot disagree — the property `updateMany` gets for free
922+
// by drawing its `targetIds` from table rows.
923+
const resolvedIndexes = updates.map((u) => table.findIndex((r) => r.id == u.id));
924+
const touchedIds = new Set(
925+
resolvedIndexes.filter((index) => index !== -1).map((index) => table[index].id),
926+
);
911927
const settled = table.filter((r) => !touchedIds.has(r.id));
912928

913929
const perUpdate: Array<{ index: number; row: Record<string, any> } | null> = [];
914930
const pending: Record<string, any>[] = [];
915931

916-
for (const u of updates) {
917-
const index = table.findIndex((r) => r.id == u.id);
932+
// Indexed rather than `for…of`, to read each id's ALREADY-resolved index:
933+
// resolving a second time here is what let the two lookups drift apart.
934+
for (let position = 0; position < updates.length; position++) {
935+
const u = updates[position];
936+
const index = resolvedIndexes[position];
918937
if (index === -1) {
919938
if (this.config.strictMode) {
920939
this.logger.warn('Record not found for bulk update', { object, id: u.id });

0 commit comments

Comments
 (0)