fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

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

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

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

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face - #14914

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id
Sep 3, 2026
Merged

fix(drivers): update() on a missing id answers null on MongoDB and Turso's remote face#14914
os-musk merged 5 commits into
mainfrom
claude/issue-14428-driver-update-missing-id

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14428

Ruling implemented: posture A — maintainer, 2026-09-03 (「同意」, decision batch #15 item 1, recorded on the card by the director seat). Both drivers return null on a miss; B (throw) was not taken, because it would have been a third posture on top of the two this card exists to collapse.

Generics are written in square brackets throughout — Promise[Record[string, unknown] | null] — because the body sanitizer eats the angle-bracket spelling.

What was wrong

IDataDriver.update() declares Promise[Record[string, unknown] | null]. Four of six shipped implementations return null for an id that names no row. Two invented a record instead:

SiteThe fabricating expression
packages/drivers/driver-mongodb/src/mongodb-driver.ts:422 (at the merge base)return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
packages/drivers/driver-turso/src/remote-transport.ts:1549 (at the merge base)return rows[0] || { id, ...data };

Both are the same shape: UPDATE/updateOne matches nothing, the read-back comes back empty, and the caller is handed a row assembled from its own payload — on Mongo with the updated_at the driver had just stamped.

This is the expensive direction of wrong, not merely the wrong answer. It says succeeded where the truth is not found, and says it in a shape carrying the caller's own fields back, so nothing about it looks wrong. Through the engine's by-id door a REST / SDK / MCP update against a deleted or mistyped id answered 200 with a record that does not exist — on these two implementations only.

The change

Two one-line returns, plus the declared type on each:

  • MongoDBDriver.update() (mongodb-driver.ts:430 declaration, :449 return) → return (updated as Record[string, unknown] | null) ?? null;, declared Promise[Record[string, unknown] | null].
  • RemoteTransport.update() (remote-transport.ts:1564 declaration, :1580 return) → return rows[0] ?? null;, declared Promise[Record[string, unknown] | null].

Two seams needed no edit, and the PR pins both so that stays true:

  • TursoDriver.update()'s remote branch wraps the transport result in formatRemoteRow, which already guards row && typeof row === 'object' — so null passes through untouched and the driver's two faces converge. That guard is load-bearing now in a way it was not before, hence a pin. TursoDriver.update() itself is untouched by this PR and still declares Promise[any].
  • RemoteTransport.bulkUpdate()'s if (updated) results.push(updated) (remote-transport.ts:1676 on this branch) — the cross-driver skip convention SqlDriver.bulkUpdate follows — stops being dead code. On this transport updated could never be falsy, so a batch over N missing ids answered N invented rows.

upsert() is untouched on both drivers: an upsert never answers "not found". packages/spec is untouched — the contract side was already ruled and landed by #13878 / PR #14434.

Semver: minor, and BREAKING for TypeScript consumers

The changeset was patch/patch on the first head. That understated the change, and it is now minor/minor with a **BREAKING** sentence. Two things moved on published surfaces, not one:

  1. A published declared return narrows.MongoDBDriver.update() and RemoteTransport.update() are both exported from their package index (driver-turso/src/index.ts:38 exports RemoteTransport). Their declared return moves from Promise[Record[string, unknown]] to Promise[Record[string, unknown] | null], so a consumer that reads a field off either result — result.id, result.title — stops compiling until it narrows. That is the shape ADR-0087's 2026-08-30 addendum names, and it says the token is owed: a published type-surface narrowing "declares **BREAKING** truthfully … ⛔ Dropping the token is no longer an available exit".
  2. Runtime behaviour moves too. The value a caller receives for a missing id changes on two published drivers. This is not a .d.ts-only release.

Disposition: exactly one marker, not-required (no-migration-prescription). No metadata key is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta, spec-changes.json or the upgrade guide to project, and the changeset prescribes no rewrite; the consumer obligation is a compiler-delivered narrowing at the call site. type-surface-only is not claimable, on two independent grounds: its predicate 4 (narrowed-from-erased) is false — neither declared return was any at the merge base — and runtime behaviour moves in the same diff. The precedent is one day earlier in this same series: .changeset/driver-memory-update-upsert-honest-types.md (PR #14434, 93940d492) carries minor, **BREAKING** and that same disposition.

pnpm check:adr-0087-registration reads it back: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. … [BREAKING] not-required (no-migration-prescription).

Zone 2 measurements (re-measured on this branch, not inherited)

A — both sites fabricate today. Confirmed, file:line and expressions quoted above.

B — convergence, not invention. Measured across every async update(object implementation in the repo:

ImplementationMiss posture before this PR
InMemoryDriver (memory-driver.ts:677)return null (with a strictMode throw branch)
SqlDriver (sql-driver.ts:6864)return this.formatOutput(object, updated) || null
SqliteWasmDriverinherits SqlDriver — overrides no update
TursoDriver local branch (turso-driver.ts:779)super.updateSqlDrivernull
MongoDBDriverfabricates
RemoteTransportfabricates

No third posture exists in-repo. strictMode is a driver-memory config only — grep finds it in neither driver-mongodb, driver-turso nor driver-sql — so no strict-throw arm was owed here.

C — no in-repo caller depends on the fabricated record. Not a stop condition.

Three readers of the changed returns, each checked:

  1. packages/objectql/src/engine.ts:11332 — the by-id driver exit. Downstream, result reaches hydrateWriteFormulas (filters r != null at engine.ts:1411), coerceBooleanFields (returns the row unchanged when !row, record-validator.ts:455) and the id read at engine.ts:11613, guarded typeof result === 'object' && result && 'id' in result. All three already receive null from four of six drivers today, so this PR adds no new exposure to that path.
  2. packages/drivers/driver-turso/src/turso-driver.ts:778 — pass-through via the null-safe formatRemoteRow.
  3. packages/drivers/driver-turso/src/remote-transport.ts:1676bulkUpdate's skip, which is written fornull and was unreachable.

Outside the two packages, the argument rests on four facts, each re-measured on this head. ⚠️ The first head's version of this paragraph asserted that every import was dynamic and that two carried as any. Both were false; the corrected enumeration is below, and the conclusion survives on the legs that are true.

  • Exactly four workspace packages declare either driver — enumerated by reading every workspace package.json's dependencies/devDependencies/peerDependencies/optionalDependencies: @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-datasource.
  • Zero re-exports.git grep "export .*from '@objectstack/driver-(mongodb|turso)'" across packages/** (excluding CHANGELOGs) returns nothing, so no fifth package can hold these types transitively through an export.
  • Six import sites, and none of them reads a field off an update() result. Four are dynamic import(...): runtime/src/turso-driver-factory.ts:210, service-datasource/src/default-datasource-driver-factory.ts:1219 and :1314 (these three carry the as any cast) and cli/src/utils/storage-driver.ts:522 (which does not). The remaining two are static: cli/src/utils/storage-driver.test.ts:7 is import type { TursoDriverConfig }, a type-only import of a config type; packages/qa/dogfood/test/date-bucket-parity-turso.test.ts:67 is a static value import of TursoDriver, and that file contains no .update( call at all (greped).
  • TursoDriver.update()'s declared type did not move. It is Promise[any] at turso-driver.ts:777, and git diff origin/main...HEAD -- turso-driver.ts is empty. So the one statically-imported driver class in the repo asks nothing new of its one caller.

Consumers otherwise reach these drivers through IDataDriver, which has declared the | null arm since #13878.

Coverage — net-new, with controls

No landed test pinned the miss posture on either driver (the existing update() cases all read rows that EXIST), so nothing here changes a baseline.

  • packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts4 cases: the miss, a no-fabrication pin naming the shape it forbids, a write-still-issued pin (a "fix" that read first would answer null correctly and silently stop writing), and a positive control.
  • packages/drivers/driver-turso/src/turso-update-missing-id.test.ts10 cases, all ten named: (1) the declared-return-type pin, (2) seeded the fixture (the premise), (3) the transport miss, (4) the no-fabrication pin, (5) the write-still-issued pin, (6) the positive control, (7) the bulkUpdate mixed-batch pin (2 present + 2 missing ids ⇒ 2 rows, and the writes that could land did), (8) the local/remote parity pin, (9) its parity positive control, and (10) the formatRemoteRow pass-through pin.

Why the type pin lives only in the turso file

driver-mongodb's tsconfig.json excludes **/*.test.ts, so the package's owntypecheck script cannot see a type pin written in a test file there: tsc --noEmit --listFiles in that package lists 0 files ending .test.ts, against 43 in driver-turso (whose tsconfig excludes only node_modules/dist). vitest transpiles without typechecking, and the root tsconfig.json excludes packages entirely.

⚠️ The first head said such a pin would be "checked by nothing", and the test header said "never checked by anything". Both were false, and this PR is the proof.pnpm check:type-check-debt re-measures that package with its tests un-hidden and compares against the frozen TEST_DEBT entry — so a broken Equals const there would surface, as a ledger count moving rather than as a named assertion failure. Both statements are corrected in this round, in the body and in mongodb-update-missing-id.test.ts. The exclusion itself is filed as #14917. The mongo declaration is pinned instead by mongodb-driver.ts being in the package's own program (leg 3 below).

Verification

All commands run on the final head f528ddb17 (git rev-parse --short HEAD), a merge of origin/main taken after the gate deriver reported STALE TREE.

The debt-ledger red, reproduced and then cleared.Type Check · debt ledger failed on the previous head 64a11c8b2: @objectstack/driver-mongodb: TEST_DEBT records 10 raw tsc error(s), tsc --noEmit now reports 13 (+3). The three were TS18047 'result' is possibly 'null' at mongodb-driver.test.ts:158,159,160 — found-arm reads of driver.update() that the widened declaration no longer permits, invisible to pnpm typecheck because that package hides its tests. Reproduced locally by rebuilding the ratchet's own generated project (remeasureProject: extend the package tsconfig, drop only the test glob from exclude, restore the default typeRoots) and running tsc --noEmit over it with the dependency closure built:

headtotalbreakdown
64a11c8b2 (before)13TS1309 x7, TS2550 x3, TS18047 x3
f528ddb17 (final)10TS1309 x7, TS2550 x3

10 is exactly the ledger's frozen entry (TS1309 x7, TS2550 x3). ⛔ The ledger was not raised — the fix is at the three call sites, narrowed with this file's own findOne idiom (assert the found arm, then read through it).

Testspnpm --filter @objectstack/driver-mongodb --filter @objectstack/driver-turso run test:

driver-mongodb Test Files 25 passed | 5 skipped (30)
Tests 552 passed | 147 skipped (699)
driver-turso Test Files 43 passed (43)
Tests 1156 passed (1156)

(The 147 skips are the pre-existing opt-in mongodb-memory-server suites, #5517.)

Typecheck — both packages: tsc --noEmit, Done, exit 0.

Lint — a declared narrowing, not a skip.pnpm lint scans the whole repo and is CI's run. Locally, pnpm exec eslint --no-inline-config --format json was run over the diff, with the three readings a narrowing owes: (1) the population is read from eslint's own config, not guessed — asked about .changeset/driver-update-missing-id-null.md, eslint answers File ignored because no matching configuration was supplied, so the changeset is outside the linted set by the config's own account and the five .ts paths are the whole linted portion of this diff; (2) the count comes from the JSON output — 5 files linted, 0 errors, 0 warnings; (3) the config enables no type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules — stated and measured in eslint.config.mjs:327-335), so nothing in these five files can move the verdict on a file this PR does not touch.

Gates — family derived on the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments, so the change set comes from git: 6 paths vs merge base 7317cf266). 41 commands, all run, exit codes captured before any pipe. 37 exit 0. The other 4 exit 3, each script's own NOT-MEASURED code rather than a finding — quoting their verdict lines:

  • check-test-completeness.mjs — "Nothing was measured … ⛔ It is NOT a finding".
  • check-half-states.mjs — "Treat this exit as an unread instrument, never as a quiet board" (needs repo-scoped egress this container lacks).
  • check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass"; its self-test passed (93 cases).
  • check:type-check-debt — "--re-measure cannot run: 18 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding".

⚠️ Three of those need a whole-repo build this container does not do; the fourth needs egress it does not have. CI supplies all four — and on the previous head the fourth one was RED. That is not a footnote: it is the finding this round exists to clear, and it is why the local substitute above (the ratchet's own project shape, one package, closure built) is reported as a measurement rather than the gate being waved through.

node scripts/pm/check-governed-merges.mjs --test on the final 6-file list: 0 of 6 path(s) hit the register — NOT governed.

Reverse verification — four legs, each re-measured on the final head. Every mutation is proved on disk by counting the injected and the deleted text (never by an editor's exit code — one leg's first attempt was refused by that check for a 2-occurrence anchor, and was re-run with a unique one). Every restore is proved by a git hash-object match against the HEAD blob and an empty git diff HEAD; each script carries a trap … EXIT INT TERM with absolute paths.

LegMutationPredictedObserved
1restore Mongo's fabricating fallbackmiss + no-fabrication red; controls greenTests 2 failed | 2 passed (4) — exactly the miss pin and the no-fabrication pin
2restore Turso's fabricating fallbackmiss, no-fabrication, bulkUpdate, parity, pass-through redTests 5 failed | 5 passed (10) — exactly those five, by name
3narrow Mongo's declared type backtsc red in the driver, proving the declaration is pinned without a test-file type pinsrc/mongodb-driver.ts(449,5): error TS2322: Type 'Record[string, unknown] | null' is not assignable to type 'Record[string, unknown]'
4narrow Turso's declared type backtsc red at the type pinsrc/turso-update-missing-id.test.ts(113,7): error TS2322: Type 'true' is not assignable to type 'false'

⚠️Legs 1 and 2 have no compile-time half, and the first head predicted one. Restoring an expression leaves both declarations untouched, so no Equals const can red and no file fails to typecheck: all four mongo cases and all ten turso cases ran. The turso parity pin is likewise one assertion over both faces, not two halves that can red independently. Both test-file headers said otherwise and are corrected in this round. Legs 1 and 2 need no rebuild — both test files import the code under test by relative specifier, so vitest resolves them to this package's src, not to a built dist. Legs 3 and 4 are tsc runs against the packages' own programs, with the dependency closures rebuilt on this head first.

Labels

needs:contract-review stays applied — it is cleared only at reviewer PASS, and only by the review. The card-side carrier of that dual-carrier label is the PM seat's to set.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

…rso remote
`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and
four of six shipped implementations return `null` for an id that names no row.
`MongoDBDriver.update()` and `RemoteTransport.update()` fabricated a record
instead — the caller's own payload with the id stapled on (and, on Mongo, the
`updated_at` the driver had just stamped). Through the engine's by-id door that
surfaced as a 200 with a record that does not exist.
Both now return `null`. `TursoDriver.update()`'s remote branch needed no edit:
`formatRemoteRow` already guards `row && typeof row === 'object'`, so the two
faces of that driver converge. `RemoteTransport.bulkUpdate()`'s
`if (updated) results.push(updated)` skip stops being dead code.
`upsert()` is untouched on both: an upsert never answers "not found".
Regression pins added per driver (net-new — no landed test pinned the
fabricating posture), each with a positive control so "return null always"
cannot pass, plus a local/remote parity pin on TursoDriver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-mongodb, @objectstack/driver-turso, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoDBDriver (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via MongoDBDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 9 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 b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 02ce5b7fc21fb43e6940bab6290bd579d86bf454 — the merge of head f528ddb17a7a65bf0cd73561ddd43ce800004642 into base b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf, 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 02ce5b7fc21fb43e6940bab6290bd579d86bf454 && git checkout 02ce5b7fc21fb43e6940bab6290bd579d86bf454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf f528ddb17a7a65bf0cd73561ddd43ce800004642 && git checkout -B drift-repro b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf && git merge --no-ff f528ddb17a7a65bf0cd73561ddd43ce800004642
node scripts/docs-audit/affected-docs.mjs --json b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b3a63d32c9a0ec291a8be1f8f43cfc7c48c802bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-muskand others added 3 commits September 3, 2026 10:43
…ongodb-driver.test.ts
The widened `update(): Promise<Record<string, unknown> | null>` declaration made
`expect(result.title|.status|.id)` three TS18047 errors. The package tsconfig
excludes `**/*.test.ts`, so `pnpm typecheck` could not see them; the type-check
debt ratchet re-measures with the tests un-hidden and caught the +3 (10 -> 13).
Narrowed at the three sites with the file's own `findOne` idiom -- assert the
found arm, then read through it. Re-measured with the ratchet's own project
shape: back to exactly 10 (TS1309 x7 + TS2550 x3), the ledger's frozen entry.
The ledger is NOT raised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ers to what was measured
Contract-review round on PR #14914.
Changeset: `patch`/`patch` understated a published TYPE-surface narrowing plus a
runtime behaviour change on two published drivers. Now `minor`/`minor` with a
`**BREAKING**` sentence naming what breaks for TypeScript consumers, and exactly
one ADR-0087 disposition -- `not-required (no-migration-prescription)`, the same
shape and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md`
(PR #14434) one day earlier in this series. `type-surface-only` is not claimable:
its predicate 4 (narrowed-from-erased) is false, and runtime behaviour moves too.
`mongodb-update-missing-id.test.ts`: the header claimed a type-level pin here
would be "never checked by anything". False -- `pnpm check:type-check-debt`
re-measures this package with its tests un-hidden, which is exactly how CI caught
the three TS18047 the widened declaration introduced. The section now states both
programs, names the tsconfig exclusion as the filed defect (#14917), and gives the
real reason the pin lives in the turso twin instead. Its reverse-verification
paragraph now reports the OBSERVED leg (2 failed | 2 passed (4), all four ran)
rather than predicting a compile-time red for a type pin this file does not have.
`turso-update-missing-id.test.ts`: same correction. Restoring the fabricating
EXPRESSION leaves the declaration untouched, so the `Equals` const cannot red and
nothing fails at compile time; the parity pin is one assertion, not two halves;
and the no-fabrication pin, omitted before, does red. The paragraph now names all
five reds and all five greens from the run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@os-musk
os-musk marked this pull request as ready for review September 3, 2026 12:04
@os-musk
os-musk enabled auto-merge September 3, 2026 12:04
@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit ca3fd4bSep 3, 2026
41 checks passed
@os-musk
os-musk deleted the claude/issue-14428-driver-update-missing-id branch September 3, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude