Skip to content

Commit 6059b29

Browse files
os-billclaude
andauthored
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>
1 parent 6b2ec3b commit 6059b29

3 files changed

Lines changed: 145 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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. -->

packages/spec/src/contracts/scoped-context.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,97 @@ describe('[#5945] the evidence bar is enforced, not just documented', () => {
289289
});
290290
});
291291

292+
describe('[#16786] `updateById` DECLARES its answer, and the declaration is enforced', () => {
293+
/**
294+
* The remainder of #16786, after PR #17255 landed the `objectql` half.
295+
*
296+
* ## Why a probe and not an `@ts-expect-error`
297+
*
298+
* The same reason the module header above gives: a directive is satisfied by
299+
* ANY error on the next line. The sibling pin on `IDataEngine`
300+
* (`data-engine.test.ts`, #16231) can use directives because it leans on
301+
* TS2578 — widening a member back makes the directive UNUSED, which is
302+
* itself an error — but that mechanism is only read by
303+
* `check:test-typecheck`, a gate OUTSIDE this suite. These probes fail in
304+
* vitest itself.
305+
*
306+
* ## The failure this pin exists to catch, measured rather than assumed
307+
*
308+
* Reverting the member to `Promise<any>` does NOT redden the `legal-*`
309+
* probes above: `legal-updateById` discards the result, and `any` compiles
310+
* everywhere a narrow type does. It reddens exactly the first case below,
311+
* and it reddens it by reporting NOTHING — an absent diagnostic IS the `any`
312+
* reading. That is why the assertion is `toContain`, on a probe whose legal
313+
* twin must stay clean: a harness that resolved nothing would report an
314+
* empty string for BOTH, so the clean twin is the anti-vacuity control and
315+
* `harness-self-test` is the "can this harness report at all" control.
316+
*
317+
* ## And why the diagnostic must NAME the shape
318+
*
319+
* The header records a phantom check caught only by running the reversal: a
320+
* code-only assertion survived the revert because a DIFFERENT type produced
321+
* the same code. So the assertion names `Record<string, any> | null` — the
322+
* declared answer. `any` cannot produce that text, and neither can the
323+
* `update` sibling's wider `Record<string, any> | number | null`, so a
324+
* copy-paste of the wrong sibling's shape is caught too.
325+
*/
326+
it('the answer cannot enter a record slot unnarrowed, and the null-checked read compiles', () => {
327+
const results = compileProbes({
328+
// The whole point of the declaration: a by-id update that matched no row
329+
// answers `null`, and before this the call site was handed `any` and
330+
// never asked.
331+
'rejects-unnarrowed-updateById':
332+
hook("const row: Record<string, any> = await ctx.api!.object('task').updateById('t1', { status: 'done' });"),
333+
// Anti-vacuity twin: the CORRECT spelling must still compile clean, so
334+
// the narrowing is a real answer shape and not a blanket refusal.
335+
'legal-updateById-null-checked': hook([
336+
"const row = await ctx.api!.object('task').updateById('t1', { status: 'done' });",
337+
'if (row !== null) {',
338+
' const seen: unknown = row.status;',
339+
' void seen;',
340+
'}',
341+
].join('\n')),
342+
'harness-self-test': hook("const x: number = ctx.api?.object('user');"),
343+
});
344+
345+
expect(render(results.get('harness-self-test')!), 'the harness must be able to report an error')
346+
.toContain('TS2322');
347+
348+
const refused = render(results.get('rejects-unnarrowed-updateById')!);
349+
// An EMPTY string here is the `Promise<any>` reading — this is the leg that
350+
// goes red on a revert.
351+
expect(refused, 'an unnarrowed record slot must be refused').toContain('TS2322');
352+
expect(refused, 'the refusal must name the DECLARED answer shape, not merely error')
353+
.toContain('Record<string, any> | null');
354+
// ⛔ Not the `update` sibling's shape: `updateById` never reaches the
355+
// predicate exit, so the affected-row count limb must not be declared here.
356+
expect(refused, 'the count limb belongs to `update`, not to `updateById`')
357+
.not.toContain('number | null');
358+
359+
expect(render(results.get('legal-updateById-null-checked')!), 'the null-checked read must compile clean')
360+
.toBe('');
361+
});
362+
363+
/**
364+
* The runtime half, so the contract is implementable at the narrowed shape
365+
* in BOTH directions — a record and the `null` a by-id miss resolves.
366+
*/
367+
it('is implementable at the declared shape, null limb included', async () => {
368+
const rows: Record<string, any>[] = [{ id: 'p1', status: 'open' }];
369+
const repo: Pick<IScopedObjectRepository, 'updateById'> = {
370+
updateById: async (id, data) => {
371+
const found = rows.find((r) => r.id === id);
372+
if (!found) return null;
373+
Object.assign(found, data);
374+
return found;
375+
},
376+
};
377+
378+
expect(await repo.updateById('p1', { status: 'filled' })).toEqual({ id: 'p1', status: 'filled' });
379+
expect(await repo.updateById('nope', { status: 'filled' })).toBeNull();
380+
});
381+
});
382+
292383
describe('[#5945] IScopedContext is implementable', () => {
293384
it('accepts a minimal implementation', async () => {
294385
const rows = [{ id: 'c1', stage: 'hired', position_id: 'p1' }];

packages/spec/src/contracts/scoped-context.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@
111111
* `IDataEngine`'s — `find` stays `Promise<any[]>`, and since #16231 `findOne`
112112
* and `update` carry the same declared answer shapes their `IDataEngine`
113113
* counterparts do — and nothing here claims to be the query schema.
114+
*
115+
* [#16786] `updateById` joins them. Ruling A on #16231 settled the RULE —
116+
* #15823's `find()` narrowing extends to the sibling doors — and enumerated
117+
* `:148` / `:164`; this member is a door of that family the enumeration
118+
* missed, so the reason it narrows is the MEASUREMENT, not the enumeration:
119+
* `Promise<any>` was wider than the engine door it forwards to
120+
* (`IDataEngine.update`, `Promise<Record<string, any> | number | null>`),
121+
* wider than that door's by-id driver exit (`IDataDriver.update`,
122+
* `Promise<Record<string, unknown> | null>`), and wider than what every
123+
* implementation in the repository returns. `packages/objectql`'s
124+
* `ObjectRepository.updateById` declared `Promise<any>` to MATCH this member,
125+
* not independently of it — PR #17255 said so in its own docblock — so this
126+
* is the half that was left open, not a second one.
114127
*/
115128

116129
import type { EngineTransactionInfo, EngineTransactionOptions } from './objectql-engine.js';
@@ -164,8 +177,23 @@ export interface IScopedObjectRepository {
164177
*/
165178
update(data: any, options?: Record<string, unknown>): Promise<Record<string, any> | number | null>;
166179

167-
/** Update a single record by id — the id travels as the first argument. */
168-
updateById(id: string | number, data: any): Promise<any>;
180+
/**
181+
* Update a single record by id — the id travels as the first argument.
182+
*
183+
* Answers the written record, or `null` when the id matched nothing. This
184+
* is the `update` by-id exit and nothing else: the implementation binds
185+
* both the payload id and a pure-id `where` and never declares `multi`, so
186+
* `resolveEngineUpdateDispatch` returns `by-id` for every call this
187+
* signature admits, and the `number` limb `update` carries — the
188+
* affected-row COUNT a predicate write resolves (#4639) — is unreachable
189+
* from here. One door down, `IDataDriver.update(object, id, data)` declares
190+
* exactly `Promise<Record<string, unknown> | null>`.
191+
*
192+
* ⚠️ A falsy id is not a narrower answer, it is a REFUSAL: `0` and `''`
193+
* identify no row, so the dispatch rejects and the call throws rather than
194+
* resolving `null`.
195+
*/
196+
updateById(id: string | number, data: any): Promise<Record<string, any> | null>;
169197
}
170198

171199
/**

0 commit comments

Comments
 (0)