Skip to content

Commit 3cbcedb

Browse files
claude[bot]claude
andauthored
feat(driver-sql,driver-turso): the remaining IDataDriver doors publish their declared types, not any (#15267) (#17258)
* feat(driver-sql,driver-turso): the remaining IDataDriver doors publish their declared types, not any (#15267) `SqlDriver` published an explicit `Promise<any>` over five doors the contract had already declared narrower — `findOne`, `create`, `bulkCreate`, `execute` and `explain` — so the emitted `.d.ts` erased every one of them. `TursoDriver` overrides four of the same five with its own `Promise<any>`, which no driver-sql fix reaches. Each annotation is replaced with the type `packages/spec/src/contracts/data-driver.ts` already declares for that door, and each is pinned at the type level inside its own package's tsc program. No runtime behaviour changes. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(driver-sql,driver-turso): narrow the consumer sites the declared doors surfaced (#15267) The narrowing surfaced 68 un-narrowed dereferences, every one in the two packages' own tests: 64 reads of a `findOne()` result with no `null` check (`expect(...).not.toBeNull()` does not narrow), and four reads through the `unknown` that `bulkCreate()`, `explain()` and `findOne()` now resolve to. Each positive control asserts the row arm with vitest's `assert()` — a narrowing assertion, not a `!` and not a cast — and each `unknown` read names what it reads. The not-found controls keep their `toBeNull()` and gain nothing. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(driver-sqlite-wasm): narrow the inherited-door consumer sites (#15267) `SqliteWasmDriver` overrides none of the five doors and inherits every one, so the driver-sql narrowing reaches its callers through that package's `.d.ts`. Eight positive controls assert the row arm; one `create()` read names the string it collects. No source change in this package. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * fix(runtime): read the record number as a string in the autonumber parity probe (#15267) `SqlDriver.create()` declares the contract's `Record<string, unknown>` now, so the cross-side parity probe's `rec_no` read is `unknown` where it was reached through an `any`. It converts to the string the probe compares. Caught by this package's `check:test-typecheck` gate, not by `tsc --noEmit` — the file is in the checked test zone and the ledger does not cover it. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): declare the driver-sql / driver-turso door narrowing (#15267) Both graded `minor` and marked `type-surface-only` under ADR-0087, matching the landed precedent PR #15280 for `update()` on the same classes. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): name only predicate-4-verifiable symbols in the ADR-0087 markers (#15267) `isErasedType` counts `unknown` as erased by design (pinned TSO-U6), so the `execute` / `explain` doors — which move onto the contract's own `unknown` — cannot serve as predicate-4 evidence. The markers name the three doors that move onto concrete shapes and state the rest in prose; the disposition is identical for every door. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a256f18 commit 3cbcedb

24 files changed

Lines changed: 536 additions & 30 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise<any>` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record<string, unknown> | null` — it has always answered `results[0] || null`), `create()` (`Record<string, unknown>`), `bulkCreate()` (`Record<string, unknown>[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder.
8+
9+
Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes.
10+
11+
`@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry.
12+
13+
Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations.
14+
15+
<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-sql/src/sql-driver.ts#findOne, packages/drivers/driver-sql/src/sql-driver.ts#create, packages/drivers/driver-sql/src/sql-driver.ts#bulkCreate) Published driver methods' declared returns move off an explicit `any` onto the contract's own shapes. 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. The same change to `execute` and `explain` is not named above because their destination is the contract's own `unknown`, which `isErasedType` counts as erased (TSO-U6), so predicate 4 cannot read them as narrowed-from-erased; they carry the identical disposition and the body states them in full. -->
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise<any>`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`.
8+
9+
Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record<string, unknown> | null`, `Record<string, unknown>`, `Record<string, unknown>[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes.
10+
11+
`explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations.
12+
13+
<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-turso/src/turso-driver.ts#findOne, packages/drivers/driver-turso/src/turso-driver.ts#create, packages/drivers/driver-turso/src/turso-driver.ts#bulkCreate) Published driver method overrides' declared returns move off an explicit `any` onto the contract's own shapes; 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. The same change to `execute` is not named above because its destination is the contract's own `unknown`, which `isErasedType` counts as erased (TSO-U6), so predicate 4 cannot read it as narrowed-from-erased; it carries the identical disposition and the body states it in full. -->

packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
* swept underneath them — the card's own end-to-end table, executed.
3737
*/
3838

39-
import { afterAll, afterEach, describe, expect, it } from 'vitest';
39+
import { afterAll, afterEach, describe, expect, it, assert } from 'vitest';
4040
import { SqlDriver } from './sql-driver.js';
4141
import {
4242
MYSQL_CELL,
@@ -335,6 +335,7 @@ describe('#11389 — the write and filter paths keep reading a Date on the UTC c
335335
await driver.create('deal', { id: 'd1', close_date: new Date(iso) }, { bypassTenantAudit: true });
336336

337337
const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true });
338+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
338339
expect(row.close_date).toBe(expected);
339340

340341
// The filter path takes the same helper, so it has to agree — a
@@ -396,7 +397,9 @@ function declareZoneSweep(cell: DialectCell): void {
396397
const d = await connect();
397398
await underProcessZone(tz, async () => {
398399
const day = await d.findOne(TABLE, { where: { id: 'r1' } }, { bypassTenantAudit: true });
400+
assert(day !== null, 'findOne answered the not-found arm for a seeded id');
399401
const ny = await d.findOne(TABLE, { where: { id: 'r2' } }, { bypassTenantAudit: true });
402+
assert(ny !== null, 'findOne answered the not-found arm for a seeded id');
400403

401404
expect(day.close_date, `${cell.label} read the wrong calendar day under TZ=${tz}`).toBe(DAY);
402405
// A one-day skew here changes the YEAR, which is the most legible

packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
* milliseconds — what `String(Date)` does — would be visible too.
6767
*/
6868

69-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
69+
import { describe, it, expect, beforeAll, afterAll, assert } from 'vitest';
7070
import type { DriverQuery } from '@objectstack/spec/contracts';
7171
import { SqlDriver } from './index.js';
7272
import {
@@ -224,6 +224,7 @@ function measure(cell: DialectCell): void {
224224

225225
it('§A3 findOne(), and the rows update() and create() return, present the same shape', async () => {
226226
const one = await driver.findOne(TABLE, { where: { id: 'r1' } }, OPTS);
227+
assert(one !== null, 'findOne answered the not-found arm for a seeded id');
227228
expect(one, 'findOne returned nothing').toBeTruthy();
228229
for (const col of INSTANT_COLUMNS) expectCanonicalInstant(one[col], `findOne ${col}`);
229230
expect(one.closed_at).toBe(CLOSED_AT[1]);
@@ -267,6 +268,7 @@ function measure(cell: DialectCell): void {
267268
// (`readback.first()` → `formatOutput`), so its return is a whole row and
268269
// the guard applies unqualified.
269270
const before = await driver.findOne(TABLE_RETURNS, { where: { id: 'w0' } }, OPTS);
271+
assert(before !== null, 'findOne answered the not-found arm for a seeded id');
270272
expect(before, 'the seed row is missing').toBeTruthy();
271273
const merged = await driver.upsert(TABLE_RETURNS, { id: 'w0', title: 'write row 0 (merged)' }, undefined, OPTS);
272274
const inserted = await driver.upsert(
@@ -556,6 +558,7 @@ describe('#13973 §D — find(), distinct() and aggregate() present the audit co
556558
await driver.create(T_RAW, { id: 'x1', n: 2 }, OPTS);
557559
await (driver as any).knex(T_RAW).where('id', 'x1').update({ updated_at: '2026-01-10 09:00:00' });
558560
const legacy = await driver.findOne(T_RAW, { where: { id: 'x1' } }, OPTS);
561+
assert(legacy !== null, 'findOne answered the not-found arm for a seeded id');
559562
expect(legacy.updated_at).toBe('2026-01-10T09:00:00.000Z');
560563
const distinct = await driver.distinct(T_RAW, 'updated_at', undefined, OPTS);
561564
expect(distinct).toContain('2026-01-10T09:00:00.000Z');

packages/drivers/driver-sql/src/sql-driver-advanced.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3+
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
44
import { SqlDriver } from '../src/index.js';
55

66
describe('SqlDriver Advanced Operations (SQLite)', () => {
@@ -191,6 +191,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
191191
await driver.commitTransaction(trx);
192192

193193
const result = await driver.findOne('orders', { where: { id: 'trx1' } });
194+
assert(result !== null, 'findOne answered the not-found arm for a seeded id');
194195
expect(result).toBeDefined();
195196
expect(result.customer).toBe('TxUser');
196197
} catch (e) {
@@ -253,6 +254,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
253254
expect(created).toBeDefined();
254255

255256
const updated = await driver.findOne('orders', { where: { id: '1' } });
257+
assert(updated !== null, 'findOne answered the not-found arm for a seeded id');
256258
expect(updated.status).toBe('shipped');
257259

258260
const deleted = await driver.findOne('orders', { where: { id: '5' } });
@@ -285,6 +287,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
285287
await driver.create('nullable_test', { id: '1', name: null, value: null });
286288

287289
const result = await driver.findOne('nullable_test', { where: { id: '1' } });
290+
assert(result !== null, 'findOne answered the not-found arm for a seeded id');
288291
expect(result).toBeDefined();
289292
expect(result.name).toBeNull();
290293
expect(result.value).toBeNull();
@@ -360,6 +363,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
360363

361364
it('should handle findOne with query parameter', async () => {
362365
const result = await driver.findOne('orders', { where: { customer: 'Charlie' } });
366+
assert(result !== null, 'findOne answered the not-found arm for a seeded id');
363367

364368
expect(result).toBeDefined();
365369
expect(result.customer).toBe('Charlie');

packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* instead of a 500.
1616
*/
1717

18-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18+
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
1919
import { SqlDriver } from '../src/index.js';
2020

2121
describe('SqlDriver array/object field persistence', () => {
@@ -64,6 +64,7 @@ describe('SqlDriver array/object field persistence', () => {
6464
{ bypassTenantAudit: true },
6565
);
6666
const row = await driver.findOne('zoo', { where: { id: 'z1' } }, { bypassTenantAudit: true });
67+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
6768
expect(row.tags).toEqual(['x', 'y']);
6869
expect(row.ms).toEqual(['red', 'green']);
6970
expect(row.cbs).toEqual(['email', 'push']);
@@ -77,12 +78,14 @@ describe('SqlDriver array/object field persistence', () => {
7778
await driver.create('zoo', { id: 'z2', name: 'B', tags: ['a'] }, { bypassTenantAudit: true });
7879
await driver.update('zoo', 'z2', { tags: ['a', 'b', 'c'] }, { bypassTenantAudit: true });
7980
const row = await driver.findOne('zoo', { where: { id: 'z2' } }, { bypassTenantAudit: true });
81+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
8082
expect(row.tags).toEqual(['a', 'b', 'c']);
8183
});
8284

8385
it('does not crash on an empty array', async () => {
8486
await driver.create('zoo', { id: 'z3', name: 'C', ms: [] }, { bypassTenantAudit: true });
8587
const row = await driver.findOne('zoo', { where: { id: 'z3' } }, { bypassTenantAudit: true });
88+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
8689
expect(row.ms).toEqual([]);
8790
});
8891
});

packages/drivers/driver-sql/src/sql-driver-autonumber-batch-resync.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ describe('[#6943] batch and upsert re-seed a stale autonumber counter', () => {
154154
const numbers = await allNumbers();
155155
expect(new Set(numbers).size).toBe(numbers.length); // no duplicate anywhere in the table
156156
// Every row of the batch sits above the seeded range it straddled.
157-
for (const r of created) expect(Number(r.case_number.slice('CASE-'.length))).toBeGreaterThan(39);
157+
// [#15267] `bulkCreate()` resolves to `Record<string, unknown>[]` now, so the
158+
// record number is read as the string it is rather than off an `any`.
159+
for (const r of created) expect(Number(String(r.case_number).slice('CASE-'.length))).toBeGreaterThan(39);
158160
});
159161

160162
it('re-seeds only the counter that went stale, leaving a co-tenant in the same batch alone', async () => {

packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3+
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
44
import { SqlDriver } from '../src/index.js';
55

66
/**
@@ -51,6 +51,7 @@ describe('SqlDriver bulkCreate JSON marshaling (#2735)', () => {
5151

5252
// Read-back parity: JSON columns decode to objects, same as single insert.
5353
const v1 = await driver.findOne('venue', { where: { id: 'v1' } });
54+
assert(v1 !== null, 'findOne answered the not-found arm for a seeded id');
5455
expect(v1.location).toEqual({ lat: 47.6062, lng: -122.3321 });
5556
expect(v1.tags).toEqual(['a', 'b']);
5657
expect(v1.meta).toEqual({ tier: 1 });

packages/drivers/driver-sql/src/sql-driver-date-only.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* behaviour and guard that `Field.datetime` keeps its full-instant meaning.
1414
*/
1515

16-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
16+
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
1717
import { SqlDriver } from '../src/index.js';
1818

1919
describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', () => {
@@ -50,6 +50,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
5050
{ bypassTenantAudit: true },
5151
);
5252
const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true });
53+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
5354
expect(row.close_date).toBe('2026-07-15');
5455
});
5556

@@ -60,6 +61,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
6061
{ bypassTenantAudit: true },
6162
);
6263
const row = await driver.findOne('deal', { where: { id: 'd2' } }, { bypassTenantAudit: true });
64+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
6365
expect(row.close_date).toBe('2026-07-15');
6466
});
6567

@@ -70,6 +72,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
7072
{ bypassTenantAudit: true },
7173
);
7274
const row = await driver.findOne('deal', { where: { id: 'd3' } }, { bypassTenantAudit: true });
75+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
7376
expect(row.close_date).toBe('2026-07-15');
7477
});
7578

@@ -80,8 +83,12 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
8083
{ bypassTenantAudit: true },
8184
);
8285
const row = await driver.findOne('deal', { where: { id: 'd4' } }, { bypassTenantAudit: true });
86+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
8387
// datetime must retain its wall-clock time — never sliced to YYYY-MM-DD.
84-
expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z');
88+
// [#15267] `findOne()` resolves to `Record<string, unknown>` now — the
89+
// stored instant comes back as a `Date` on some clients and an ISO string
90+
// on others, which is exactly the union `Date` accepts.
91+
expect(new Date(row.signed_at as string | number | Date).toISOString()).toBe('2026-03-20T12:34:56.000Z');
8592
});
8693

8794
it('matches a date-only equality filter against a timestamped write (the silent-miss regression)', async () => {
@@ -120,6 +127,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
120127

121128
// Read-side repair: the returned value is date-only with no migration.
122129
const row = await driver.findOne('deal', { where: { id: 'legacy' } }, { bypassTenantAudit: true });
130+
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
123131
expect(row.close_date).toBe('2026-08-15');
124132

125133
// …but the value still stored in SQL keeps its time, so a SQL equality

0 commit comments

Comments
 (0)