fix(driver-memory): publish the declared return types on find(), findOne() and create() - #15498
Conversation
…/create `IDataDriver` declares `Promise<Record<string, unknown>[]>`, `Promise<Record<string, unknown> | null>` and `Promise<Record<string, unknown>>` on these three doors. The emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and `Promise<Record<string, any>>` instead: the return types were inferred through the backing store's `any[]` rows, and `create`'s existing annotation spelled `Record<string, any>`. A consumer reading `findOne()` was therefore never asked to narrow the `null` arm the contract declares. One explicit contract-typed return annotation per door — the shape #14434 landed on `update` / `upsert`, not a re-typed store (measured to cascade). Two in-package readers now narrow the `undefined` arm of `Array.find` that the `any` had hidden. Type-level pin added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…iver-memory-published-door-types
…iver-memory-published-door-types
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 83f320da5633ed2079b98aa07ebc33be7628bf58 && git checkout 83f320da5633ed2079b98aa07ebc33be7628bf58
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 61821e54cf5dba38512a3eb5adb7073660fdc935 358c98a3543e54db979da229308bf57ff26dcfd3 && git checkout -B drift-repro 61821e54cf5dba38512a3eb5adb7073660fdc935 && git merge --no-ff 358c98a3543e54db979da229308bf57ff26dcfd3
node scripts/docs-audit/affected-docs.mjs --json 61821e54cf5dba38512a3eb5adb7073660fdc935
|
ACCEPT on the work. ⛔ Not landing yet — CI has 11 checks in flight.
|
⛔ Correction to my review above (
|
Closes #14435
Executes route (a) of the card, ratified by triage (comment 5542158004): one explicit contract-typed return annotation per door, the shape PR #14434 landed one door over on
update/upsert. ⛔ Not route (b), re-typing the store — the card measured that to cascade (19 errors) and to make the write doors infer a too-narrow literal, "a second lie, not an honest type". Generic type arguments are written in SQUARE brackets throughout (the body sanitizer eats the angle-bracket spelling, backticks included).Clause-② self-reading — falsified from the diff, not inherited
No. Enumerated mechanically at the merge base
414c1fcf8: this diff touches exactly one published package,@objectstack/driver-memory, and exactly three signature-bearing lines in its non-test source — the three door returns below. No symbol is added or removed, no parameter changes, no other member is touched, and every one of the three moves toward the declarationIDataDriveralready carries. The accept set does not move. ⇒ Draft, noneeds:contract-review.The doors — published
.d.tsbefore / after, all fivepackages/drivers/driver-memory/dist/index.d.ts, built from this branch's own tree.update/upsertare the control: #14434 already un-masked them, they are untouched here, and their staying narrow across both readings is what shows the emission mechanism is the one being measured.Before (rebuilt at⚠️ the card's reading was from
845d767c4, today'sorigin/mainat branch time —2584285bcand was re-measured, not inherited):After (rebuilt at
358c98a35, this PR's head):#14434 has not regressed — the two control doors read identically in both columns.
IDataDriveras it declares them today (packages/spec/src/contracts/data-driver.ts:155/181/187, read from the tree rather than from the card's quotation):Promise[Record[string, unknown][]],Promise[Record[string, unknown] | null],Promise[Record[string, unknown]]. The three doors now publish exactly that.⭐ Triage's
createtrap, run down — and the card's causal claim is FALSIFIEDThe card and triage both warned that
createalready carries an annotation and would still emitRecord[string, any]"because its input isRecord[string, any]", concluding: "Annotating the return alone is measured insufficient on that door."Measured here: that conclusion does not hold, and the stated cause is not the cause. The annotation on
createexisted but itself spelledPromise[Record[string, any]]— it named the contract's arity without its element type. Correcting that one spelling toPromise[Record[string, unknown]]moves the emitted type directly, with the input parameter left exactly as it was (data: Record[string, any], unchanged in this diff — see the after-column above, where the parameter is stillanyand the return is not). An explicit return annotation is not inferred through anything, so the input cannot reach it.⇒ Route (a) is sufficient on all three doors,
createincluded. The card's own alternative reading — "createalready has a comment saying its annotation was added for exactly this reason" — is the accurate half: the annotation was right to exist and merely had the wrong element type. The stale comment at that site has been corrected in place so the next reader is not sent after the input again.What the narrowing surfaced (the substance of the card)
An
anyhides errors, so removing three of them is a question, not a formality. Exactly 2 errors surfaced, both real readers, both in-package, and neither was widened back or cast away:Both are
Array.prototype.find(...)results read straight through —raw.find(r => r.id === 'd_midnight').created_at. Whilefind()publishedany[],Array.findreturnedanyand theundefinedarm was invisible; a row that failed to seed raised aTypeErrorfrom inside the assertion instead of failing it. Both now assert the row's presence and then narrow, which is a real assertion the suite did not previously make. ⛔ Noas anywas added and no declared type was widened to make these green.Note the arm here is
undefinedfromArray.find, not thenullarm offindOne— different door, different residue.④ The
nullarm — what it costs a consumerTriage named this the residue: "the remaining risk is a consumer never being asked to narrow the
nullarm". Measured, in this repo, it costs nothing today, and that is a census result rather than an assumption:InMemoryDriverat all (git grep, whole repo,*.ts):examples/embed-objectql/src/index.tsand twopackages/runtimeintegration tests. None of the three reads afindOne()result.IDataDriver— which already declared| null. Those call sites were being asked to narrow before this PR and are unaffected by it.@objectstack/driver-memory(cli,plugin-dev,runtime,service-datasource,example-embed-objectql); all five typechecked green here, fresh rather than cached.⇒ The obligation lands on an out-of-repo consumer of the published package that imports the concrete class and reads a
findOne()result: it becomes a compile error until thenullarm is narrowed. That is the breaking-ness the changeset declares, and it is exactly the hazard the card was filed about — the arm was always reachable at runtime (results[0] || null), only never visible.Pins
New
memory-find-create-declared-types.test.ts— type-level pins inside the package's tsc program, plus runtime cases that make the consts observable. It pins both halves: the CONTRACT (3 consts, read through@objectstack/spec's built.d.ts) and the DRIVER (7 consts,IsAnyplus exactEquals).One const earns its place explicitly:
memoryFindRowIsAnychecksMemoryFind[number], becauseIsAny[any[]]is false — an array whose rows areanyis not itselfany, so an arity-only check passes throughout the defect. The ablation below confirms it:memoryFindIsAnyandmemoryCreateIsAnydo not fire on the reverted tree, and were kept only as the honest statement of what they do cover.update/upsertare deliberately not re-pinned; they carry their own pin from #13878.Ablation — direction predicted BEFORE, measured after; both legs, both proven on disk
Implementation committed first, so both legs restore against a
HEADthat actually contains it. Every mutation is anchored (injected-count and deleted-count both grepped, blob compared against the HEAD blob — never a baregit diff --stat), every restore runs from atrap ... EXIT INT TERMwith absolute paths, and each is proven by the whole-tree state, not by an exit code.Leg A — revert the three driver annotations alone (contract untouched). driver-memory's own tsc reads its
src, so this leg needs no rebuild, and the spec half of the pin is unaffected. Predicted: RED at exactly 5 consts —memoryFindIsContract,memoryFindOneIsAny,memoryFindOneIsContract,memoryCreateIsContract,memoryFindRowIsAny— withmemoryFindIsAnyandmemoryCreateIsAnystaying green for theIsAny[any[]]reason above. Measured: exactly 5, at lines 61 / 62 / 63 / 65 / 69, which are those five consts and no others. Restore proven byte-exact: blob back to24f354c0…== HEAD blob,git diff HEADempty,git statusclean.Leg B — revert the CONTRACT's three declarations alone (driver annotations stay). driver-memory resolves
@objectstack/specthroughdist, so this leg carries a rebuild on both halves. Mutation: source anchored, blobf0604958…vs HEAD6d884a4d…,pnpm --filter @objectstack/spec build, thennode scripts/ablation-dist-preflight.mjs @objectstack/spec '...' --absent→✓ dist/: marker absent from all 217 built files. Predicted: RED at exactly the 3 contract consts, with the 7 driver-side consts staying green. Measured: exactly 3, at lines 55 / 56 / 57. Restore leg run in full rather than assumed: source blob back to6d884a4d…, spec rebuilt, preflight without--absent→✓ dist/: marker present in 2 built files, driver-memory tsc back to 0 errors, and the whole-treegit status --porcelainempty — the check the preflight itself demands over a per-path diff.⇒ Both halves of the new pin are shown to fail on the defect they exist to catch. Neither is decorative.
Consumer-closure verification
Direction: prefix —
--filter '...@objectstack/driver-memory'= the 20 downstream consumers, not the upstream deps the suffix form would select.turbo build --filter '...@objectstack/driver-memory'— 68 / 68 successful. Each consumer's dts emit is a type check of its own published surface against the new.d.ts.turbo typecheck --filter '...@objectstack/driver-memory'— 84 / 84 successful, 0 errors. 18 ran fresh (cache miss), and they are the ones that matter:cli,runtime,plugin-auth,plugin-dev,service-datasource,client,client-react,rest,http-conformance,verify,dogfood,service-sms,driver-memoryand the five examples.pnpm --filter(not turbo) failed inservice-datasourcewith 3×TS2307: Cannot find module '@objectstack/driver-sql'. That is an unbuilt sibling — pnpm's prefix filter selects consumers but not the consumers' own dependencies — i.e. an environment precondition, not a consequence of this change. Turbo's^buildresolves it, and the run above is green.Gates
Derived on the merged head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(its stderr confirms the answer comes from this repo at358c98a35, and the--repoassertion holds against the checkout's own remote). The tool takes the change set from the merge base itself; no hand-built path list was fed to it. Harvested with--commands: 46 lines, asserted against the Reconciliation line's own total of 46 families — matched block, convention block and always-runs tail together, not one section.All 46 measured green on the final head
358c98a35. Every exit code captured after a redirect, never through a pipe.Named verdict lines rather than bare codes:
check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.·check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live·check-type-check-coverage: OK — 75/79 workspace packages type-checked·check-type-check-coverage --re-measure: OK — 13 ledger entr(ies) re-measured, 143 raw tsc error(s) total, none above its recorded numberwithsurplus: none·check-nul-bytes: OK (scanned 7496 text file(s) … no raw ASCII control bytes)·check-test-source-alias OK — 72 packages with tests scanned·check:cross-package-test-inputs OK: 26 package(s) read outside themselves, all declared·check:published-files — 69 publishable package(s) ….Two non-measurements, resolved rather than reported as passes:
check:dual-build-cjs-loadsfirst returned exit 3 (PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured) against a partially built tree, and is green once the closure build satisfies it;check:type-check-debtfirst returned exit 124, which was my own 300s wrapper timeout on a shared box and not a red — re-run without that cap, it is the green quoted above.Also run beyond the derived family, against the real diff rather than their self-tests:
check-adr-0087-registration(✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition),check-changeset-no-major(✓ This diff introduces no major bump),check-empty-changeset(✓ … 1 declaring changeset(s) added).pnpm lint(eslint . --no-inline-config, whole repo) — exit 0. The full scan completed inside the foreground budget, so no narrowing is claimed.pnpm --filter @objectstack/driver-memory teston the final head: Test Files 41 passed (41) · Tests 1039 passed (1039);typecheckexit 0, 0 errors. The union above was run after the last commit and is quoted from that head,358c98a35.Changeset — the level derived from the rule, not from memory
@objectstack/driver-memory: minor, carrying BREAKING and an ADR-0087 disposition.Derivation against the maintainer rule landed today (batch #35 on #15294), whose text lives in the
Check Changesetprose ofpr-automation.yml: neither of its two named limbs covers this act. It is not "a purely additive widening" (no new exported symbol, no new accepted key or value), and it is not "afix(that changes no public surface" — it changes a published surface. What the rule does settle is that the act wins over the commit type, and the act here is a type-surface narrowing that is compile-breaking for a consumer holding the concrete class. Under strict semver that ismajor;check-changeset-no-majorrefusesmajorduring the launch window, leavingminorandpatch, and a compile-breaking surface change cannot sit below theminorfloor the rule sets for a merely additive one. ⇒minor, with the BREAKING banner and the ADR-0087 disposition carrying the breaking-ness, exactly as that guard's header says they must during the window. The same act on the neighbouring doors shipped this way in #14434.Disposition
not-required (no-migration-prescription), one of the five categories the gate accepts: no metadata key is removed, renamed or re-shaped, no spec schema changes at all (unlike #14434, this diff touchespackages/drivers/driver-memory/**only), and nothing exists forobjectstack migrate metato rewrite — the obligation is a TypeScript narrowing at the call site, delivered by the compiler.Scope kept
⛔
memory-matcher.tsis not touched — PR #15464 is live in this package on that file, and the two are disjoint at file granularity, which is the test this lane has been applying. ⛔ The backing store keeps itsany[]rows: re-typing it is route (b), measured to cascade, and it is not attempted here. ⛔ The three doors' parameters are unchanged — narrowing an input is a second, unrelated break, and method parameters compare bivariantly against the contract'sRecord[string, unknown], so the declaration is satisfied as it stands. ⛔ No otherIDataDrivermember, no other driver —SqlDriver.update's explicitPromise[any]stays with #13854 per the card's own note. ⛔ No ratchet re-baselined, no test deleted or weakened.On the #5499 freeze
The 2026-08-05 ruling freezes defect-repair and semantic-completion investment in
driver-memory/driver-mongodb. This PR stays on the published-type side of that line and did not drift: no runtime behaviour changes — no control flow, no return value, no error path is edited; the entire non-test source diff is the three return annotations plus comments, quoted in full in the Clause-② section above. The two test edits add narrowing the compiler now demands, and the new file is a pin.check:driver-memory-censusis green, and it is the gate that polices that boundary.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code