Skip to content

Commit 2200f8e

Browse files
claude[bot]claude
andauthored
feat(driver-sql,driver-turso): update() publishes the contract's Record<string, unknown> | null, not any (#15280)
`SqlDriver.update()` was written out with an explicit `Promise<any>` while it has always answered a missing id with `null`; `IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` and an explicit `any` satisfies that structurally, so the published `.d.ts` read `any` and no caller holding the class was asked to narrow. The annotation is now the contract's, on `update()` and on its protected rotation-path producer `rotatedUpdateById()`. `TursoDriver` overrides the door rather than inheriting it, with its own explicit `Promise<any>`; its override is narrowed the same way and carries its own changeset. `SqliteWasmDriver` inherits: its emitted `.d.ts` declares no `update` member (measured), so it carries a pin and no changeset. Type-level pins (`IsAny` = false, `Equals` the contract shape) live in each of the three packages' own tsc programs; each was measured red (2 x TS2322) against the pre-change annotation and green after. The narrowing surfaced six sites in driver-turso's own tests that read fields off an `update()` result after asserting `not.toBeNull()`; they now narrow through vitest's `assert()` (an assertion signature), not a `!` or a cast. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed217e6 commit 2200f8e

9 files changed

Lines changed: 346 additions & 7 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
feat(driver-sql): `update()` publishes its honest type — the contract's `Record<string, unknown> | null`, not `any` (#14438)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 used for the same door on `@objectstack/driver-memory`). `SqlDriver.update()` was written out with an explicit `Promise<any>` while it has always answered a missing id with `null` (`formatOutput(...) || null` on the un-rotated path, `null` once every rotation shard has been probed). `IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`, and an explicit `any` satisfies that structurally — so the emitted `.d.ts` read `Promise<any>` and no caller holding a `SqlDriver`, or a `SqliteWasmDriver` (which inherits the door unchanged), was ever asked to narrow. It is now declared as the contract declares it, and the protected rotation-path producer `rotatedUpdateById()` carries the same type. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members now types them. No runtime behaviour changes.
8+
9+
`@objectstack/driver-sqlite-wasm` re-declares no `update` member of its own (measured on its emitted `.d.ts`), so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides the door and carries its own entry.
10+
11+
<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-sql/src/sql-driver.ts#update) A published driver method's declared return moves off an explicit `any` onto the contract's own shape. No metadata key is removed, renamed or re-shaped, `packages/spec` is untouched, and nothing exists for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, delivered by the compiler. -->
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
feat(driver-turso): the `update()` override publishes its honest type — `Record<string, unknown> | null`, not `any` (#14438)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` overrides `update()` rather than inheriting it, and the override was written out with its own explicit `Promise<any>` — so this package's emitted `.d.ts` re-declared the door as `any` on its own and would not have picked up the `@objectstack/driver-sql` narrowing. Both of its branches already answered the contract's type: the local branch forwards to `SqlDriver.update()` (narrowed alongside, #14438) and the remote branch passes `RemoteTransport.update()`'s `Record<string, unknown> | null` (#14428) through the generic `formatRemoteRow`. The override now declares what it answers. A caller that read fields off the result through the `any` now narrows the `null` arm first. No runtime behaviour changes.
8+
9+
<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-turso/src/turso-driver.ts#update) A published driver method's declared return moves off an explicit `any` onto the contract's own shape; no metadata key moves, `packages/spec` is untouched, and the obligation is a TypeScript narrowing at the consumer's own call site, delivered by the compiler. -->
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14438 — `SqlDriver.update()`'s declared return type is the contract's, not
4+
// `any`, and it carries the not-found arm.
5+
//
6+
// `SqlDriver.update()` has always answered a missing id with `null`
7+
// (`return this.formatOutput(object, updated) || null` on the un-rotated path;
8+
// the rotation path's `return null` once every shard has been probed), while
9+
// its signature was written out as an EXPLICIT `Promise<any>`.
10+
// `IDataDriver.update()` declares `Promise<Record<string, unknown> | null>`
11+
// (the arm landed with #13878 / PR #14434 under the maintainer's 2026-09-01
12+
// ruling), and an explicit `any` satisfies that structurally — so `tsc` said
13+
// nothing, the published `.d.ts` of `@objectstack/driver-sql` read
14+
// `Promise<any>`, and no caller holding a `SqlDriver` (or a `SqliteWasmDriver`,
15+
// which inherits the door) was ever asked to narrow. It is the mask
16+
// `InMemoryDriver` carried, inferred there and written out here.
17+
//
18+
// This file pins BOTH halves at the type level, inside this package's tsc
19+
// program (`tsconfig.json` selects `src/**/*`, tests included, and the package
20+
// carries no DEBT / TEST_DEBT entry in `scripts/check-type-check-coverage.mjs`):
21+
//
22+
// 1. the CONTRACT: `IDataDriver.update()` resolves to
23+
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
24+
// built `.d.ts`, so reverting the declaration alone reds this file;
25+
// 2. the DRIVER: `SqlDriver.update()` is not `any` and resolves to exactly
26+
// the contract's type — putting the annotation back to `Promise<any>` reds
27+
// this file too: `IsAny` flips to `true` and `Equals` to `false`.
28+
//
29+
// Reverse verification, direction predicted BEFORE it was run: with the source
30+
// annotation at `Promise<any>`, `pnpm --filter @objectstack/driver-sql typecheck`
31+
// fails with TS2322 on `sqlUpdateIsAny` and on `sqlUpdateIsContract` (two
32+
// errors, both in this file), while `pnpm test` stays green — the type-level
33+
// facts are carried by consts vitest only compares. That split is the point:
34+
// this defect has no runtime face, which is why an assignability-only pin
35+
// would have passed against the very `any` being removed. Measured as
36+
// predicted: 2 × TS2322 (this file, the two driver consts) with the annotation
37+
// at `Promise<any>`, 0 errors with it at the contract's type.
38+
//
39+
// The typed-const form is the one `memory-update-declared-null.test.ts`
40+
// (driver-memory, #13878) and `sql-driver-distinct-filter-narrowing.test.ts`
41+
// use; the runtime cases below make the consts observable so the file is a
42+
// test and not a declaration. `SqliteWasmDriver` and `TursoDriver` carry their
43+
// own copies of the driver half in their own tsc programs.
44+
45+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
46+
import type { Knex } from 'knex';
47+
import type { IDataDriver } from '@objectstack/spec/contracts';
48+
import { SqlDriver } from './index.js';
49+
50+
/** `any` defeats ordinary assignability checks; this is the standard detector. */
51+
type IsAny<T> = 0 extends 1 & T ? true : false;
52+
/** Exact (mutual, non-`any`) type equality. */
53+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
54+
55+
type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
56+
type SqlUpdate = Awaited<ReturnType<SqlDriver['update']>>;
57+
58+
// 1. The contract declares the not-found arm.
59+
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;
60+
61+
// 2. The driver's door is un-masked and reads exactly as the contract does.
62+
const sqlUpdateIsAny: IsAny<SqlUpdate> = false;
63+
const sqlUpdateIsContract: Equals<SqlUpdate, Record<string, unknown> | null> = true;
64+
65+
describe('SqlDriver.update() declared return type (#14438)', () => {
66+
let driver: SqlDriver;
67+
let knexInstance: Knex;
68+
69+
beforeEach(async () => {
70+
driver = new SqlDriver({
71+
client: 'better-sqlite3',
72+
connection: { filename: ':memory:' },
73+
useNullAsDefault: true,
74+
});
75+
// `knex` is `protected` on SqlDriver; name the single member being reached
76+
// rather than erasing the driver with `as any` (#6204 spelling).
77+
knexInstance = (driver as unknown as { knex: Knex }).knex;
78+
await knexInstance.schema.createTable('t', (t: Knex.CreateTableBuilder) => {
79+
t.string('id').primary();
80+
t.string('name');
81+
});
82+
await knexInstance('t').insert({ id: '1', name: 'before' });
83+
});
84+
85+
afterEach(async () => {
86+
await knexInstance.destroy();
87+
});
88+
89+
it('pins the contract and the driver at the type level', () => {
90+
expect([contractUpdateDeclaresNull, sqlUpdateIsAny, sqlUpdateIsContract]).toEqual([true, false, true]);
91+
});
92+
93+
it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
94+
const result = await driver.update('t', 'missing', { name: 'x' });
95+
expect(result).toBeNull();
96+
97+
// The narrowing the declared type now demands of every caller: a field
98+
// read is only reachable behind the `null` check.
99+
const name = result === null ? 'absent' : result.name;
100+
expect(name).toBe('absent');
101+
});
102+
103+
it('update() on an existing id resolves to the updated record, behind the same narrowing', async () => {
104+
const result = await driver.update('t', '1', { name: 'after' });
105+
expect(result).not.toBeNull();
106+
expect(result === null ? 'absent' : result.name).toBe('after');
107+
});
108+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6905,7 +6905,16 @@ export class SqlDriver implements IDataDriver {
69056905
}
69066906
}
69076907

6908-
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any> {
6908+
/**
6909+
* [#14438] Declared as `IDataDriver.update()` declares it: the updated
6910+
* record, or `null` when no row carries `id` — the un-rotated path answers
6911+
* `formatOutput(...) || null` and the rotation path answers `null` once
6912+
* every shard has been probed. The annotation used to be an explicit
6913+
* `Promise<any>`, which an un-narrowed caller could read fields off with no
6914+
* compiler complaint; it is the contract's type now, pinned by
6915+
* `sql-driver-update-declared-null.test.ts`.
6916+
*/
6917+
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
69096918
this.auditMissingTenant(object, 'update', options);
69106919
const rotationShards = this.rotationShardsOf(object);
69116920
if (rotationShards) return this.rotatedUpdateById(object, rotationShards, id, data, options);
@@ -7987,7 +7996,7 @@ export class SqlDriver implements IDataDriver {
79877996
id: string | number,
79887997
data: Record<string, any>,
79897998
options?: DriverOptions,
7990-
): Promise<any> {
7999+
): Promise<Record<string, unknown> | null> {
79918000
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
79928001
// [#11067] One definition of the decision, shared with {@link update}. No
79938002
// fallback is threaded here, and that is a property of the path rather than
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14438 — `SqliteWasmDriver.update()` reads as the contract declares it, not
4+
// as `any`, and it carries the not-found arm.
5+
//
6+
// `SqliteWasmDriver extends SqlDriver` and does NOT override `update()`, so its
7+
// published `.d.ts` re-declares no `update` member of its own: the door it
8+
// exposes is `SqlDriver.update()`'s, read through `@objectstack/driver-sql`'s
9+
// built `.d.ts`. That is exactly the case that is easy to get wrong in both
10+
// directions — a reader assumes the sibling "must have its own copy", or
11+
// assumes inheritance and never measures it. This pin makes the inheritance a
12+
// measured fact inside THIS package's tsc program (`tsconfig.json` selects
13+
// `src/**/*`, tests included; no DEBT / TEST_DEBT entry): the door is not
14+
// `any` and resolves to exactly `Record<string, unknown> | null`.
15+
//
16+
// Two ways this file goes red, both by design:
17+
// - `@objectstack/driver-sql` puts its `update()` annotation back to
18+
// `Promise<any>` (the pre-#14438 state) — this package inherits the mask
19+
// again and `IsAny` flips;
20+
// - a future override in `sqlite-wasm-driver.ts` re-declares the door with a
21+
// widened type — `Equals` flips, naming the drift here rather than at a
22+
// consumer.
23+
//
24+
// Reverse verification, direction predicted BEFORE it was run: against the
25+
// pre-change `@objectstack/driver-sql` `.d.ts`, `pnpm --filter
26+
// @objectstack/driver-sqlite-wasm typecheck` fails with TS2322 on
27+
// `wasmUpdateIsAny` and on `wasmUpdateIsContract`; `pnpm test` is green
28+
// either way (type-level facts carried by consts). Measured as predicted:
29+
// 2 × TS2322 against the pre-change `.d.ts`, 0 against the narrowed one — and
30+
// the built `dist/index.d.ts` of this package declares no `update(` member
31+
// (0 matches), confirming the door is inherited, not re-declared.
32+
33+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
34+
import type { Knex } from 'knex';
35+
import type { IDataDriver } from '@objectstack/spec/contracts';
36+
import { SqliteWasmDriver } from './index.js';
37+
38+
/** `any` defeats ordinary assignability checks; this is the standard detector. */
39+
type IsAny<T> = 0 extends 1 & T ? true : false;
40+
/** Exact (mutual, non-`any`) type equality. */
41+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
42+
43+
type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
44+
type WasmUpdate = Awaited<ReturnType<SqliteWasmDriver['update']>>;
45+
46+
// 1. The contract declares the not-found arm.
47+
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;
48+
49+
// 2. The inherited door is un-masked and reads exactly as the contract does.
50+
const wasmUpdateIsAny: IsAny<WasmUpdate> = false;
51+
const wasmUpdateIsContract: Equals<WasmUpdate, Record<string, unknown> | null> = true;
52+
53+
describe('SqliteWasmDriver.update() declared return type (#14438)', () => {
54+
let driver: SqliteWasmDriver;
55+
56+
beforeEach(async () => {
57+
driver = new SqliteWasmDriver({ filename: ':memory:' });
58+
// `knex` is `protected` on the base; name the single member being reached.
59+
const k = (driver as unknown as { knex: Knex }).knex;
60+
await k.schema.createTable('t', (t: Knex.CreateTableBuilder) => {
61+
t.string('id').primary();
62+
t.string('name');
63+
});
64+
await k('t').insert({ id: '1', name: 'before' });
65+
});
66+
67+
afterEach(async () => {
68+
await driver.disconnect();
69+
});
70+
71+
it('pins the contract and the inherited door at the type level', () => {
72+
expect([contractUpdateDeclaresNull, wasmUpdateIsAny, wasmUpdateIsContract]).toEqual([true, false, true]);
73+
});
74+
75+
it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
76+
const result = await driver.update('t', 'missing', { name: 'x' });
77+
expect(result).toBeNull();
78+
79+
// The narrowing the declared type now demands of every caller.
80+
const name = result === null ? 'absent' : result.name;
81+
expect(name).toBe('absent');
82+
});
83+
84+
it('update() on an existing id resolves to the updated record, behind the same narrowing', async () => {
85+
const result = await driver.update('t', '1', { name: 'after' });
86+
expect(result).not.toBeNull();
87+
expect(result === null ? 'absent' : result.name).toBe('after');
88+
});
89+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14438 — `TursoDriver.update()`'s declared return type is the contract's, not
4+
// `any`, and it carries the not-found arm.
5+
//
6+
// `TursoDriver` does not merely inherit `SqlDriver.update()` — it OVERRIDES it
7+
// (a local branch that forwards to `super.update`, a remote branch that passes
8+
// `RemoteTransport.update()`'s result through the generic `formatRemoteRow`),
9+
// and the override was written out with its own explicit `Promise<any>`. Both
10+
// branches already carried the honest type: `SqlDriver.update()` is narrowed
11+
// by #14438 and `RemoteTransport.update()` declared
12+
// `Promise<Record<string, unknown> | null>` with #14428. The override's
13+
// annotation was the one place the family's honest type was re-erased, so
14+
// this package's published `.d.ts` re-declared the door as `any` on its own —
15+
// which is why "TursoDriver inherits the fix" would have been wrong, and why
16+
// this pin lives in THIS package's tsc program rather than in driver-sql's.
17+
//
18+
// Pinned here, at the type level (`tsconfig.json` selects `src/**/*`, tests
19+
// included; no DEBT / TEST_DEBT entry for this package):
20+
//
21+
// 1. the CONTRACT: `IDataDriver.update()` resolves to
22+
// `Record<string, unknown> | null`;
23+
// 2. the DRIVER: `TursoDriver.update()` is not `any` and resolves to exactly
24+
// the contract's type — putting the override's annotation back to
25+
// `Promise<any>` reds this file: `IsAny` flips to `true`, `Equals` to
26+
// `false`.
27+
//
28+
// Reverse verification, direction predicted BEFORE it was run: with the
29+
// override at `Promise<any>`, `pnpm --filter @objectstack/driver-turso typecheck`
30+
// fails with TS2322 on `tursoUpdateIsAny` and on `tursoUpdateIsContract`; `pnpm
31+
// test` stays green either way (type-level facts carried by consts). Measured
32+
// as predicted: 2 × TS2322 on this file with the override at `Promise<any>`;
33+
// with the override narrowed, this file is clean and the package's remaining
34+
// errors are the consumer sites the narrowing was written to surface.
35+
//
36+
// The runtime case below drives the LOCAL face (`:memory:`); the remote face's
37+
// `null` on a miss is pinned by the `RemoteTransport` suites (#14428).
38+
39+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
40+
import type { IDataDriver } from '@objectstack/spec/contracts';
41+
import { TursoDriver } from './turso-driver.js';
42+
43+
/** `any` defeats ordinary assignability checks; this is the standard detector. */
44+
type IsAny<T> = 0 extends 1 & T ? true : false;
45+
/** Exact (mutual, non-`any`) type equality. */
46+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
47+
48+
type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
49+
type TursoUpdate = Awaited<ReturnType<TursoDriver['update']>>;
50+
51+
// 1. The contract declares the not-found arm.
52+
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;
53+
54+
// 2. The override is un-masked and reads exactly as the contract does.
55+
const tursoUpdateIsAny: IsAny<TursoUpdate> = false;
56+
const tursoUpdateIsContract: Equals<TursoUpdate, Record<string, unknown> | null> = true;
57+
58+
/**
59+
* The slice of the inherited (protected) Knex instance this fixture touches.
60+
* `knex` is not a dependency of this package, so its types are not imported;
61+
* naming the members reached keeps the harness of a file about "the door is
62+
* no longer `any`" free of `any` itself.
63+
*/
64+
type TableBuilder = { string(name: string): { primary(): unknown } };
65+
type KnexSlice = {
66+
schema: { createTable(name: string, build: (t: TableBuilder) => void): Promise<unknown> };
67+
} & ((table: string) => { insert(row: Record<string, unknown>): Promise<unknown> });
68+
69+
describe('TursoDriver.update() declared return type (#14438)', () => {
70+
let driver: TursoDriver;
71+
72+
beforeEach(async () => {
73+
driver = new TursoDriver({ url: ':memory:' });
74+
const k = (driver as unknown as { knex: KnexSlice }).knex;
75+
await k.schema.createTable('t', (t) => {
76+
t.string('id').primary();
77+
t.string('name');
78+
});
79+
await k('t').insert({ id: '1', name: 'before' });
80+
});
81+
82+
afterEach(async () => {
83+
await driver.disconnect();
84+
});
85+
86+
it('pins the contract and the override at the type level', () => {
87+
expect([contractUpdateDeclaresNull, tursoUpdateIsAny, tursoUpdateIsContract]).toEqual([true, false, true]);
88+
});
89+
90+
it('update() on a missing id resolves to null on the local face, and the declared type makes the caller narrow', async () => {
91+
const result = await driver.update('t', 'missing', { name: 'x' });
92+
expect(result).toBeNull();
93+
94+
// The narrowing the declared type now demands of every caller.
95+
const name = result === null ? 'absent' : result.name;
96+
expect(name).toBe('absent');
97+
});
98+
99+
it('update() on an existing id resolves to the updated record, behind the same narrowing', async () => {
100+
const result = await driver.update('t', '1', { name: 'after' });
101+
expect(result).not.toBeNull();
102+
expect(result === null ? 'absent' : result.name).toBe('after');
103+
});
104+
});

0 commit comments

Comments
 (0)