You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec): declare IScopedObjectRepository.updateById's answer — the record or null, not any (#17836)
Fixes#16786
Generic arguments are spelled with SQUARE brackets throughout this body
— `Promise[T]`, `Record[string, any]` — so that no fragment of it is
shaped like an HTML tag. The source carries the real spelling.
## What changed
One member of one interface, plus its docblock, a pin and a changeset:
packages/spec/src/contracts/scoped-context.ts
- updateById(id: string | number, data: any): Promise[any];
+ updateById(id: string | number, data: any): Promise[Record[string,
any] | null];
PR #17255 landed this card's `objectql` half and left this member named
as the remainder; triage re-routed the card to `domain:spec` on
2026-09-10. This is that remainder and nothing else.
## Why — the measurement, ⛔ not an enumeration
⚠️ Stated plainly so the authority is not over-read: **ruling A on
#16231 did not enumerate this line.** What the ruling settled is the
RULE — #15823's `find()` narrowing extends to the sibling doors — and
its enumeration named `scoped-context.ts:148` / `:164`. `updateById` is
a door of that family the enumeration missed. It is narrowed here
because the **measurement** says the declaration was wider than every
implementation and wider than the door it forwards to.
Located by SYMBOL, re-derived at this branch's base `b59b74909c` — ⛔
every `path:line` on the card is its writer's reading at their own head
and two of them are days old.
### The declaration, and its siblings after PR #16783
| member | declared on `origin/main` before this PR |
|---|---|
| `find` | `Promise[any[]]` |
| `findOne` | `Promise[Record[string, any] \| null]` — narrowed under
ruling A |
| `count` | `Promise[number]` |
| `insert` | `Promise[any]` |
| `update` | `Promise[Record[string, any] \| number \| null]` — narrowed
under ruling A |
| **`updateById`** | **`Promise[any]`** ← this PR |
⚠️ Note for a reader expecting a fourth sibling: this interface declares
**no `delete`** member at all. `scoped-context.test.ts` pins its ABSENCE
(`rejects-delete`). Ruling A's `delete` anchor is on `IDataEngine`, a
different interface.
### What the implementations actually return
⭐ The whole defect class is that TypeScript's `implements` accepts a
**wider** declared return, so a class satisfies a narrowed interface
while publishing `any` to every caller. Shown at this member rather than
asserted:
- `packages/objectql/src/engine.ts`, `class ObjectRepository implements
IScopedObjectRepository` — `async updateById(id, data): Promise[any]`.
Its own docblock, written by PR #17255, says why: *"Its `Promise[any]`
is what `IScopedObjectRepository.updateById` itself declares, so the
class matches its contract and there is no drift to repair on this side;
that member is `packages/spec`'s to narrow and stays open on #16786."* ⇒
the class declared `any` to MATCH this member, not independently of it.
Narrowing here is the half that was left open, not a second one.
- Its body forwards to `this.engine.update(name, { ...data, id }, {
where: { id }, context })` — the `IDataEngine.update` door, which
declares `Promise[Record[string, any] | number | null]`.
- That door's by-id branch calls `driver.update(object, id, data,
options)`, and `IDataDriver.update` declares exactly
`Promise[Record[string, unknown] | null]`.
- The other implementation of the surrounding contract,
`UnscopedHookApi` (`packages/objectql/src/hook-run-as.ts`), never
returns a repository at all — `object()` throws.
- The two in-repo object literals typed at this contract
(`scoped-context.test.ts`, `hook.test.ts`) both answer a record.
⇒ `Promise[any]` was wider than every implementation AND wider than the
door it forwards to. The bar the dispatch set is met.
### Why the `number` limb is NOT declared here
`update` carries a count limb because it has two dispatch exits;
`updateById` has one. The implementation binds a truthy scalar payload
id AND a pure-id `where`, and never declares `multi`, so
`resolveEngineUpdateDispatch` (`@objectstack/metadata-core`) answers
`by-id` for every call this signature admits:
`unhonouredByIdPredicateKeys({ id })` filters out the key `id` and
returns `[]`, and the payload id equals `where.id` by construction, so
neither refusal branch is reachable. A falsy id (`0`, `''`) is a REFUSAL
rather than a narrower answer — it identifies no row, the dispatch
rejects and the call throws.
⇒ the honest answer is the by-id exit: the record, or `null`.
## Consumer census
Who reaches `updateById` **through the interface-typed door**, repo-wide
(`grep` over all `.ts` / `.tsx` / `.md` / `.mdx`, excluding
`node_modules` and `dist`):
- `packages/spec/src/contracts/scoped-context.test.ts` —
`legal-updateById` compile probe (discards the result) and a literal
`IScopedObjectRepository` returning `({ id, ...data })`. Both satisfy
the narrowed shape unchanged.
- `packages/spec/src/data/hook.test.ts` — a fake returning `({ id,
...data })`. Same.
- `packages/lint/**` — twelve sites, all of which parse the SPELLING
`ctx.api.object(…).updateById(…)` out of authored hook/action bodies as
text. None reads a return type.
- `content/docs/api/error-handling-server.mdx`,
`content/docs/automation/hook-bodies.mdx` — documented call sites; both
discard the result.
- `packages/objectql/src/engine-update-duplicate-record.test.ts` —
reaches it on the CLASS, whose own declaration this PR does not touch.
⇒ **no consumer relies on the `any`**, and nothing in the repository
needed repair. `pnpm --filter @objectstack/spec test` and `typecheck`
are green with no consumer edits in the diff. ⚠️ Out-of-repo TypeScript
consumers DO break — that is the changeset's BREAKING banner, not an
absence of breakage.
## The published surface — measured before and after, with controls
The emitted `.d.ts` is what consumers resolve, ⛔ not the source. Built
FIRST in both readings (`npm pack --dry-run --json` before a build reads
`dist` as empty).
BEFORE packages/spec/dist/analytics.zod-BuSXQiW9.d.ts:2202
updateById(id: string | number, data: any): Promise[any];
AFTER packages/spec/dist/analytics.zod-CR3DXUJM.d.ts:2230
packages/spec/dist/analytics.zod-BBKc62s9.d.mts:2230
updateById(id: string | number, data: any): Promise[Record[string, any]
| null];
The wide spelling now reads **0** anywhere under `dist/`. Controls on
both readings: `IScopedObjectRepository` reads 1 file (lit), a
fabricated symbol reads 0. The chunk is re-exported by the published
`./contracts` entry (`dist/contracts/index.d.ts` re-exports `i as
IScopedObjectRepository` from it).
⭐ A second, unplanned control fell out of the ordering: the green pin
leg ran while `dist/` still held the OLD declaration and still asserted
the NEW shape — direct proof the probe harness resolves `src/`, not
`dist/`, so the ablation below needs no rebuild leg.
## The pin, and its ablation
`packages/spec/src/contracts/scoped-context.test.ts` gains a probe-based
pin driven through `ts.createProgram` — the harness that file already
owns. ⛔ Not `@ts-expect-error`: a directive is satisfied by ANY error on
the next line, and the sibling pin's TS2578 mechanism is only read by
`check:test-typecheck`, a gate OUTSIDE this suite. These fail in vitest
itself.
- `rejects-unnarrowed-updateById` — assigning the answer into a
`Record[string, any]` slot must report **TS2322**, and the message must
NAME `Record[string, any] | null`. Naming the shape is deliberate: the
file's own header records a phantom check that survived a revert because
a code-only assertion was satisfied by a different type. It also asserts
the message does NOT contain `number | null`, so a copy-paste of the
`update` sibling's wider shape is caught.
- `legal-updateById-null-checked` — the correct null-checked spelling
must compile CLEAN. Anti-vacuity: a harness that resolved nothing would
report an empty string for both probes.
- `harness-self-test` — the existing "can this harness report at all"
control.
- a runtime leg proving the contract is implementable at the declared
shape in both directions (record, and the `null` a by-id miss resolves).
**GREEN:** `pnpm --filter @objectstack/spec exec vitest run
--maxWorkers=2 src/contracts/scoped-context.test.ts` → `Test Files 1
passed (1) · Tests 8 passed (8)`.
**ABLATION**, run from the committed state (`d3344d52a7`), mutating the
declaration back to `Promise[any]`:
HEAD blob 67f2697
on-disk, before mutation 67f2697 (equal
— tree was at HEAD)
occurrence counts BEFORE narrow=1 wide=0 -> AFTER narrow=0 wide=1
on-disk, mutated e81dba2c0d84ecd78542dfe10b5dc7dd89328ec9 (differs — the
mutation landed)
RED Tests 1 failed | 7 passed (8)
AssertionError: an unnarrowed record slot must be refused: expected ''
to contain 'TS2322'
⭐ The failure signature is the point: the probe reports **NOTHING**, and
an absent diagnostic IS the `any` reading. The other seven legs stay
green — only the leg that measures the narrowing moves, which is the
anti-vacuity shape.
RESTORE git checkout HEAD -- ABSOLUTE_PATH -> on-disk
67f2697 (= HEAD blob)
git diff HEAD: 0 lines · git status --porcelain: clean
The script carried `trap RESTORE_FN EXIT INT TERM` with an absolute path
seeded from `git rev-parse --show-toplevel`, treated an empty `git
hash-object` as FAILURE, and proved restore by blob comparison — ⛔ never
by an exit code.
## Changeset — `minor` + BREAKING, and the grade was measured, ⛔ not
inherited
`.changeset/16786-scoped-updatebyid-answer.md`, `"@objectstack/spec":
minor`, body opening **BREAKING**.
Does the changed text reach a published `dist`? Measured after a build,
with controls:
- POSITIVE — `dist/analytics.zod-*.d.ts` and `dist/contracts/index.d.ts`
are both in `npm pack --dry-run --json` (2012 files).
- NEGATIVE — `src/contracts/scoped-context.ts` is NOT: `files[]` ships
`src/**/*.zod.ts` only, and this file is not a `.zod.ts`. Zero
`src/contracts/*` paths ship.
⇒ the declaration reaches consumers through `dist` and only through
`dist`. Not a `skip-changeset` case.
Weighed against the precedent the dispatch named — PR #15280
(`SqlDriver.update()` / the `TursoDriver.update()` override), PR #14434
(`@objectstack/driver-memory`), and this card's own PR #17255,
**re-graded from `patch` to `minor` mid-round**:
⇒ **`minor` + BREAKING, and this case is STRICTLY stronger than the
precedent.** #17255's `patch` argument was *"the interface already
declared the narrow shape; the class merely re-widened on the way out,
so repairing it violates nothing new"* — and even that was refuted. Here
that argument is not available at all: the interface IS what moves, on a
published exported type. There is no reading on which this is the
`patch` rung.
ADR-0087 disposition: `not-required (no-migration-prescription)`. ⚠️
`type-surface-only` — the category built for exactly this class — was
measured and is **unavailable**, ⛔ not skipped: its predicate 2
(`no-spec-diff`) is false by construction, because the narrowed symbol
LIVES in `packages/spec`; its predicate 3 (`no-metadata-surface-diff`)
is false for the same file, `packages/spec/src/contracts/**` being an
ADR-0087 shape surface by the gate's own classifier. Two of four
predicates cannot hold for ANY edit to this symbol. The **BREAKING**
token is carried rather than dropped — that erosion is what #13080 was
filed about.
⚠️ This is a second, different reason the category is unreachable from
the one PR #17255 recorded (there: predicate 4 cannot address a class
member whose name repeats in its file; filed as #17279). Recorded so the
two are not conflated.
## Clause ②
`Clause-②: no`, as the claiming seat declared it. This round's own
measurement agrees and does not re-declare: the change adds no schema
key, no closed-set member, no published export and no registry entry —
`pnpm --filter @objectstack/spec run check:api-surface` is green with no
snapshot movement, because `api-surface/` records that an export EXISTS,
never what it resolves to. It narrows one existing member's declared
return.
## Verification
- **Pin**: green 8/8; ablation red 1/7 with the predicted signature;
restore proven by blob hash. Above.
- **Package**: `pnpm --filter @objectstack/spec test` and `pnpm --filter
@objectstack/spec typecheck` — see the report on the card for the run
figures.
- **Dependency closure** (`pnpm --filter '@objectstack/spec^...'
build`): EMPTY — `packages/spec` has no workspace dependencies (`No
projects matched the filters`). Nothing to build; declared rather than
silently skipped.
- **Derived gate families** — `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`, 81 commands, every exit
code captured BEFORE any pipe. **77 exit 0.** Four exit **3 =
PREREQUISITE NOT MET = NOT MEASURED**, ⛔ never counted green:
- `check:dual-build-cjs-loads` and `check:type-check-debt` need a
FULL-REPO build closure (87 packages / 31 dependencies unbuilt). CI owns
that build; PR #17255 reported the same two the same way.
- `check:lean-entry-closure` and `@objectstack/lint
check:doc-formula-expressions` need smaller closures, built and re-run
in this round — see the report.
- ⛔ No `#`+digits and no closing stem appears in any commit message or
trailer; swept per stem with a lit control that hit
`fixes`/`closes`/`part of`/`refs` and four `#`-digit tokens.
## Out of scope, ⛔ not folded in
- `IScopedObjectRepository.find` still declares `Promise[any[]]` and
`insert` still declares `Promise[any]`. ⛔ Deliberately untouched:
neither is implicated by this measurement. `find`'s width is ARGUED in
the file's own module header (the corpus reads rows off it), and
`insert`'s engine door `IDataEngine.insert` itself declares
`Promise[any]`, so there is nothing narrower to come back to. Narrowing
either would be a NEW declaration with an unmeasured census — the class
of change ruling A paid for.
- ⚠️ A correction to this round's dispatch order, recorded rather than
smoothed over: it stated the `.changeset/issue-N-slug` spelling has
**zero** precedents in this tree. Measured at `b59b74909c`: there are
**four** (`issue-17400-…`, `issue-17461-…`, `issue-17574-…`,
`issue-17595-…`). The numeric-prefix form the order prescribes is
nonetheless the prevailing one (50 of 227) and is what this PR uses, so
the instruction's conclusion stands and only its count was wrong.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): `IScopedObjectRepository.updateById` declares its answer — the record or `null`, not `any` (#16786)
6
+
7
+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, and PR #17255 for this card's `objectql` half, which was re-graded from `patch` to `minor` mid-round for exactly this reason).
8
+
9
+
`updateById(id, data)` declared `Promise<any>` — the last wide member of a contract whose siblings answer what they mean. It now declares `Promise<Record<string, any> | null>`: the written record, or `null` when the id matched nothing.
10
+
11
+
The declaration is what every layer under it already says, measured rather than inherited:
12
+
13
+
- the engine door it forwards to, `IDataEngine.update`, declares `Promise<Record<string, any> | number | null>`;
14
+
- that door's by-id exit calls `IDataDriver.update(object, id, data)`, which declares exactly `Promise<Record<string, unknown> | null>`;
15
+
-`packages/objectql`'s `ObjectRepository.updateById` declared `Promise<any>` to MATCH this member rather than independently of it, and PR #17255 said so in its own docblock when it deliberately left this half open.
16
+
17
+
The `number` limb `update` carries — the affected-row COUNT a predicate write resolves — is **not** declared here, and that is a measurement too: the implementation binds both the payload id and a pure-id `where` and never declares `multi`, so the shared update dispatch answers `by-id` for every call this signature admits. A falsy id (`0`, `''`) is a REFUSAL, not a `null`: it identifies no row, so the dispatch rejects and the call throws.
18
+
19
+
Ruling A on #16231 settled the rule — #15823's `find()` narrowing extends to the sibling doors — and enumerated `scoped-context.ts:148` / `:164`, not this member. It is narrowed because the measurement says the declaration was wider than every implementation and wider than the door it forwards to, ⛔ not because a ruling named it.
20
+
21
+
A hook or service that assigned the result into a record slot, or read a field off it, through an `IScopedObjectRepository`-typed door now separates the `null` arm first. No runtime behaviour changes. The in-repo census through the interface-typed door is the contract's own suites, which already answer the narrow shape.
22
+
23
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No metadata key, no authored property, no config field, no accepted request shape and no stored artifact changes spelling or shape: the edit is one declared RETURN TYPE on a TypeScript interface plus its docblock, so `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. What the change asks for is addressed to a TYPESCRIPT CONSUMER and delivered by the compiler at their own call site, which is the audience the ADR-0087 ledger explicitly does not serve.
24
+
`type-surface-only` is the category built for this class of change and it is NOT claimed here, because it is unavailable — measured, not assumed. Its predicate 2 (`no-spec-diff`) is false by construction: the narrowed symbol LIVES in `packages/spec`, so any diff that moves it touches that package. Its predicate 3 (`no-metadata-surface-diff`) is false for the same file, `packages/spec/src/contracts/**` being an ADR-0087 shape surface by the gate's own classifier. Two of the four predicates cannot hold for any edit to this symbol, so the category is out of reach on the merits rather than on a resolution defect. The **BREAKING** banner above is carried rather than dropped — that erosion is what #13080 was filed about. -->
0 commit comments