Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution by zhuangjianguo · Pull Request #13917 · objectstack-ai/objectstack · GitHub
Skip to content

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution - #13917

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry
Aug 31, 2026
Merged

fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution#13917
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-13911-bulkupdate-id-type-asymmetry

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes#13911

The defect

InMemoryDriver.bulkUpdate used two lookups over the same ids, and they disagreed:

consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.hasconstindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==

IDataDriver.bulkUpdate declares id: string | number, and this driver resolves an id to
a row with a loose comparison — as update and delete always have — so naming a stored
1 as '1' finds the same row. But Set membership is always strict. For a mixed-type
id the row was therefore resolved and updated and left in settled carrying its
pre-image, so it entered the projected row set twice: once with the value it was
vacating, once with the value it was taking. exceptId does not help — it excludes only
the row currently being checked, never a sibling row of the same batch.

Net effect: a batch that merely hands a unique value from one row to another was
refused with a false UNIQUE_VIOLATION / 409.

The fix

Resolve every id to its table index first, then derive the touched set from the
resolved rows' own ids rather than from caller input:

constresolvedIndexes=updates.map((u)=>table.findIndex((r)=>r.id==u.id));consttouchedIds=newSet(resolvedIndexes.filter((index)=>index!==-1).map((index)=>table[index].id),);

Both lookups now read the same stored value and cannot drift apart — the property
updateMany gets for free by drawing its target ids from table rows.

⛔ Deliberately not fixed by tightening findIndex to ===. That would silently
narrow which ids resolve at all — a behaviour change far wider than this defect — and
update() one method up uses ==, so this door must keep matching its sibling's
resolution semantics. The bug is the disagreement; the resolution side is the side that
had to be preserved.

The main loop now reads each id's already-resolved index instead of resolving a second
time, which is what let the two lookups drift apart in the first place. Error precedence
is unchanged: the missing-id throw and the uniqueness refusal still fire at the same point
in batch order as before.

Correcting the record on the construction

The PR that introduced this (#13875, landed at 4642f4c64c) described its bulkUpdate as
generalizing updateMany's posture. It generalized the discipline — check every
pending row's post-image before writing any of them — but not updateMany's internal
consistency
. That is the one way it diverged: updateMany's targetIds come from table
rows and its findIndex is strict ===, so both of its comparisons agree by
construction; bulkUpdate drew one side from caller input and the other from a loose
resolution. The landed changeset is left untouched (it is already merged); this note is
the correction.

bulkDelete — checked, not assumed

bulkDelete has no analogous gap, and the reason is structural rather than
coincidental: it performs exactly one id comparison (the resolving findIndex), and
everything downstream is keyed on that comparison's result — a table index — never on
caller input. There is no second lookup to disagree with the first.

That also makes its de-duplication type-proof: ['1', 1] against a stored 1 collapses to
one index. Had the set been keyed on caller ids instead, those would be two distinct
entries and the two splices would remove index 0 twice, taking a neighbouring row with it.
Both properties are now pinned by test rather than left to inspection.

exceptId semantics

assertNoUniqueViolation filters exceptIdstrictly (row.id === exceptId).
bulkUpdate already passed the storedtable[index].id there rather than the caller's
id, so self-exclusion was type-consistent and is unaffected by this fix — worth recording,
since passing caller input there would have been a second instance of the same class.

Tests

Added to memory-bulk-update-delete-atomicity.test.ts (the existing suite used string ids
throughout, which is why this passed 36/36):

  • the regression — mixed id types, the vacate-and-take batch that must succeed;
  • a positive control — the same batch shape with consistent id types, so the
    regression cannot pass vacuously;
  • the stored id keeps its own type (a string id in the batch does not restamp it);
  • a genuine collision is still refused with mixed id types (the fix does not turn the
    check off);
  • bulkDelete with a mixed-type id, and the same row named twice in two id types.

Before the fix: 1 failed | 23 passed, the failure being exactly the mixed-type case with
Unique constraint violated on doc.doc_no: a record with the value "D-0001" already exists — a value the batch had just vacated. After: 24 passed.

Full driver-memory package suite: 38 files, 1018 tests, all passing.

Ablation on the committed fix: reverting the touched-set derivation to caller input,
proven on disk with anchored greps in both directions plus a changed blob hash, turns the
new test red and only that test; restored under trap with absolute paths, verified by an
empty git diff HEAD and a blob-hash match.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 275e755a7f3183e3711b85e5661aa771ed73b6c7 — the merge of head dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e into base 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 275e755a7f3183e3711b85e5661aa771ed73b6c7 && git checkout 275e755a7f3183e3711b85e5661aa771ed73b6c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e && git checkout -B drift-repro 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3 && git merge --no-ff dcbcb1e22ae69c400adc6d9c617eb0348c67bb7e
node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
…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
…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
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:17
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit de96cf4Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13911-bulkupdate-id-type-asymmetry branch August 31, 2026 17:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory bulkUpdate: strict touchedIds.has vs loose findIndex == — a mixed id-type batch false-refuses with UNIQUE_VIOLATION

2 participants

@zhuangjianguo@claude