From b8bb9d422b3eb670ebb04795ceca217075ba5a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:07:13 +0000 Subject: [PATCH 1/6] feat(driver-sql,driver-turso): the remaining IDataDriver doors publish their declared types, not any (#15267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SqlDriver` published an explicit `Promise` 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`, 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 --- .../sql-driver-doors-declared-types.test.ts | 191 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 50 ++++- .../turso-driver-doors-declared-types.test.ts | 168 +++++++++++++++ .../drivers/driver-turso/src/turso-driver.ts | 27 ++- 4 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-doors-declared-types.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-driver-doors-declared-types.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-doors-declared-types.test.ts b/packages/drivers/driver-sql/src/sql-driver-doors-declared-types.test.ts new file mode 100644 index 0000000000..eaee08cd5a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-doors-declared-types.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #15267 — the five remaining `IDataDriver` doors on `SqlDriver` publish their +// declared return type, not `any`. +// +// #14438 (PR #15280) un-masked `update()` on this class and filed the census of +// what was left: `findOne`, `create`, `bulkCreate`, `execute` and `explain` +// each carried an EXPLICIT `Promise` while +// `packages/spec/src/contracts/data-driver.ts` had already declared every one +// of them narrower — `findOne` and `create` and `bulkCreate` as their record +// shapes, `execute` and `explain` as `unknown`. An explicit `any` satisfies all +// five structurally, so `tsc` said nothing, the published `.d.ts` of +// `@objectstack/driver-sql` read `Promise`, and no caller holding a +// `SqlDriver` (or a `SqliteWasmDriver`, which overrides none of them and +// inherits every one) was ever asked to narrow. The family was honest on the +// interface and masked on the class: a reader who had learned "the driver doors +// are narrowed now" was wrong on five of six. +// +// This file pins BOTH halves of each door at the type level, inside this +// package's own tsc program (`tsconfig.json` selects `src/**/*`, tests +// included, and the package carries no DEBT / TEST_DEBT entry in +// `scripts/check-type-check-coverage.mjs`): +// +// 1. the CONTRACT half — what `IDataDriver` declares, read through +// `@objectstack/spec`'s BUILT `.d.ts`, so a revert of the contract alone +// reds this file; +// 2. the DRIVER half — the class's door is not `any` and resolves to exactly +// the contract's type. Putting any one annotation back to `Promise` +// reds this file twice for that door: `IsAny` flips to `true` and `Equals` +// to `false`. +// +// Reverse verification, direction predicted BEFORE it was run: with the five +// source annotations back at `Promise` and this file present, +// `pnpm --filter @objectstack/driver-sql typecheck` fails with TS2322 on the +// ten driver consts below (two per door) and on nothing else, while `pnpm test` +// stays green — the type-level facts are carried by consts vitest only +// compares. That split is the point: this defect has no runtime face for the +// four pure-annotation doors, which is why an assignability-only pin would pass +// against the very `any` being removed. `findOne`'s `null` arm is the one door +// that also has a runtime face, and it is exercised below. +// +// The typed-const form is `sql-driver-update-declared-null.test.ts`'s (#14438), +// which is `memory-update-declared-null.test.ts`'s (#13878). `TursoDriver` +// overrides four of these five doors and carries its own copy of the driver +// half in its own tsc program (`turso-driver-doors-declared-types.test.ts`); +// `SqliteWasmDriver` overrides none and reaches its consumers through this +// package's `.d.ts`. +// +// Out of scope, deliberately: `analyzeQuery()` (the helper behind `explain()`) +// and `aggregate()` are not pinned here. The first is not on `IDataDriver` at +// all; the second is, but the card that authorised this change ruled both out +// of its diff by name, so neither annotation moved and neither is asserted. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Knex } from 'knex'; +import type { IDataDriver } from '@objectstack/spec/contracts'; +import { SqlDriver } from './index.js'; + +/** `any` defeats ordinary assignability checks; this is the standard detector. */ +type IsAny = 0 extends 1 & T ? true : false; +/** Exact (mutual, non-`any`) type equality. */ +type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +type Resolved = F extends (...args: never[]) => PromiseLike ? R : never; + +// `explain` is optional on the contract (`explain?(...)`), so its function type +// is read through `NonNullable` — the door is the member, not its presence. +type ContractFindOne = Resolved; +type ContractCreate = Resolved; +type ContractBulkCreate = Resolved; +type ContractExecute = Resolved; +type ContractExplain = Resolved>; + +type SqlFindOne = Resolved; +type SqlCreate = Resolved; +type SqlBulkCreate = Resolved; +type SqlExecute = Resolved; +type SqlExplain = Resolved; + +// 1. The contract half — what `IDataDriver` already declared before this change. +const contractFindOne: Equals | null> = true; +const contractCreate: Equals> = true; +const contractBulkCreate: Equals[]> = true; +const contractExecute: Equals = true; +const contractExplain: Equals = true; + +// 2. The driver half — un-masked, and reading exactly as the contract reads. +// `unknown` needs the `IsAny` leg most of all: `Equals` is +// already `false`, but a door that regressed to `any` must be named as `any` +// rather than merely "not `unknown`". +const sqlFindOneIsAny: IsAny = false; +const sqlFindOneIsContract: Equals | null> = true; +const sqlCreateIsAny: IsAny = false; +const sqlCreateIsContract: Equals> = true; +const sqlBulkCreateIsAny: IsAny = false; +const sqlBulkCreateIsContract: Equals[]> = true; +const sqlExecuteIsAny: IsAny = false; +const sqlExecuteIsContract: Equals = true; +const sqlExplainIsAny: IsAny = false; +const sqlExplainIsContract: Equals = true; + +describe('SqlDriver declared return types on the five remaining IDataDriver doors (#15267)', () => { + let driver: SqlDriver; + let knexInstance: Knex; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + // `knex` is `protected` on SqlDriver; name the single member being reached + // rather than erasing the driver with `as any` (#6204 spelling). + knexInstance = (driver as unknown as { knex: Knex }).knex; + await knexInstance.schema.createTable('t', (t: Knex.CreateTableBuilder) => { + t.string('id').primary(); + t.string('name'); + }); + await knexInstance('t').insert({ id: '1', name: 'before' }); + }); + + afterEach(async () => { + await knexInstance.destroy(); + }); + + it('pins the contract half of all five doors', () => { + expect([contractFindOne, contractCreate, contractBulkCreate, contractExecute, contractExplain]).toEqual([ + true, + true, + true, + true, + true, + ]); + }); + + it('pins the driver half of all five doors: none is `any`, each is the contract type', () => { + expect([sqlFindOneIsAny, sqlCreateIsAny, sqlBulkCreateIsAny, sqlExecuteIsAny, sqlExplainIsAny]).toEqual([ + false, + false, + false, + false, + false, + ]); + expect([ + sqlFindOneIsContract, + sqlCreateIsContract, + sqlBulkCreateIsContract, + sqlExecuteIsContract, + sqlExplainIsContract, + ]).toEqual([true, true, true, true, true]); + }); + + it('findOne() on a query that matches nothing resolves to null, and the declared type makes the caller narrow', async () => { + const result = await driver.findOne('t', { where: { id: 'missing' } }, { bypassTenantAudit: true }); + expect(result).toBeNull(); + + // The narrowing the declared type now demands of every caller: a field read + // is only reachable behind the `null` check. + const name = result === null ? 'absent' : result.name; + expect(name).toBe('absent'); + }); + + it('findOne() on a match resolves to the row, behind the same narrowing', async () => { + const result = await driver.findOne('t', { where: { id: '1' } }, { bypassTenantAudit: true }); + expect(result).not.toBeNull(); + expect(result === null ? 'absent' : result.name).toBe('before'); + }); + + it('findOne() answers null for a non-object query — the other arm of the same declared null', async () => { + const result = await driver.findOne('t', undefined as unknown as Parameters[1], { + bypassTenantAudit: true, + }); + expect(result).toBeNull(); + }); + + it('create() and bulkCreate() resolve to record shapes the declared types describe', async () => { + const created = await driver.create('t', { id: '2', name: 'via create' }, { bypassTenantAudit: true }); + expect(created.id).toBe('2'); + + const batch = await driver.bulkCreate( + 't', + [ + { id: '3', name: 'a' }, + { id: '4', name: 'b' }, + ], + { bypassTenantAudit: true }, + ); + expect(batch).toHaveLength(2); + expect(batch.map((row) => row.id)).toEqual(['3', '4']); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 40d5707258..69c81053b6 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -6051,8 +6051,15 @@ export class SqlDriver implements IDataDriver { * (field selection, temporal coercion, unknown-column recovery, and the * `singleRowLookup` ORDER BY decision). * Spell an id lookup as what it is: `{ object, where: { id } }`. + * + * [#15267] Declared as `IDataDriver.findOne()` declares it: the row, or + * `null` when nothing matches — `results[0] || null`, and `null` outright for + * a non-object query. The annotation used to be an explicit `Promise`, + * which an un-narrowed caller could read fields off with no compiler + * complaint; it is the contract's type now, pinned by + * `sql-driver-doors-declared-types.test.ts`. */ - async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise { + async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { if (!query || typeof query !== 'object') return null; const results = await this.findRows(object, { ...query, limit: 1 }, options, true); return results[0] || null; @@ -6064,7 +6071,14 @@ export class SqlDriver implements IDataDriver { // `find()` with `limit`/`offset` until a real Knex `.stream()` read is built to a // caller's requirement. - async create(object: string, data: Record, options?: DriverOptions): Promise { + /** + * [#15267] Declared as `IDataDriver.create()` declares it: the inserted + * record, `formatOutput(...)` over the `returning('*')` row. The annotation + * used to be an explicit `Promise`, so the published `.d.ts` let a + * caller read any member off the result; it is the contract's type now, + * pinned by `sql-driver-doors-declared-types.test.ts`. + */ + async create(object: string, data: Record, options?: DriverOptions): Promise> { const { _id, ...rest } = data; const toInsert = { ...rest }; @@ -7885,7 +7899,14 @@ export class SqlDriver implements IDataDriver { // Bulk & Batch Operations // =================================== - async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise { + /** + * [#15267] Declared as `IDataDriver.bulkCreate()` declares it: the inserted + * rows, each through `formatOutput()` for read-back parity with + * {@link create}. The annotation used to be an explicit `Promise`, which + * erased both the array and the row shape on the published `.d.ts`; it is the + * contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`. + */ + async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { this.auditMissingTenant(object, 'bulkCreate', options); // Same client-side id assignment as create() (id/_id normalization, // nanoid fallback when neither is supplied) — a row missing an id must @@ -8429,8 +8450,15 @@ export class SqlDriver implements IDataDriver { * through them — they handle tenancy, soft-delete, and audit warnings * automatically. See `README.md > Tenant Isolation` for the full bypass * matrix. + * + * [#15267] Declared as `IDataDriver.execute()` declares it: `unknown` — a raw + * statement's result is whatever the dialect returned, and the contract's own + * `unknown` says exactly that. The annotation used to be an explicit + * `Promise`, which erased the contract's `unknown` on the class and let + * a caller dereference the result unchecked; pinned by + * `sql-driver-doors-declared-types.test.ts`. */ - async execute(command: any, params?: any[], options?: DriverOptions): Promise { + async execute(command: any, params?: any[], options?: DriverOptions): Promise { if (typeof command !== 'string') { return command; } @@ -9237,8 +9265,18 @@ export class SqlDriver implements IDataDriver { // Query Plan Analysis // =================================== - /** IDataDriver standard: analyze query performance */ - async explain(object: string, query: DriverQuery, options?: DriverOptions): Promise { + /** + * IDataDriver standard: analyze query performance. + * + * [#15267] Declared as `IDataDriver.explain()` declares it: `unknown` — a + * query plan's shape is the dialect's, and the contract's own `unknown` says + * so. The annotation used to be an explicit `Promise`, which erased that + * `unknown` on the class; pinned by + * `sql-driver-doors-declared-types.test.ts`. {@link analyzeQuery}, the + * off-contract helper it forwards to, keeps its own annotation — it is not an + * `IDataDriver` door and is out of that card's scope. + */ + async explain(object: string, query: DriverQuery, options?: DriverOptions): Promise { return this.analyzeQuery(object, query, options); } diff --git a/packages/drivers/driver-turso/src/turso-driver-doors-declared-types.test.ts b/packages/drivers/driver-turso/src/turso-driver-doors-declared-types.test.ts new file mode 100644 index 0000000000..1a86691ec3 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-doors-declared-types.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #15267 — the `IDataDriver` doors `TursoDriver` OVERRIDES publish their +// declared return type, not `any`. +// +// The same shape #14438 fixed on this class's `update()` override, and for the +// same reason it had to be fixed here rather than inherited: `TursoDriver` +// overrides `findOne`, `create`, `bulkCreate` and `execute` with its own +// explicit `Promise` on each, so this package's published `.d.ts` +// re-declares four of the five doors as `any` on its own and picks up NOTHING +// from the `@objectstack/driver-sql` narrowing. A driver-sql-only fix would +// have left this package's consumers exactly as mis-declared as before while +// the census read clean. +// +// Both branches of every one of the four already carried the honest type +// before this change: +// +// - the LOCAL branch forwards to `super.` — narrowed in driver-sql by +// this same card; +// - the REMOTE branch passes `RemoteTransport.()` through the generic +// `formatRemoteRow` / `formatRemoteRows` (`(object, row: T): T`), and +// `RemoteTransport` already declares `findOne` as +// `Record | null`, `create` as `Record`, +// `bulkCreate` as `Record[]` and `execute` as `unknown`. +// +// So each override's annotation was pure erasure with nothing behind it — the +// one place the family's honest type was re-masked. +// +// Pinned here, at the type level, inside THIS package's tsc program +// (`tsconfig.json` selects `src/**/*`, tests included; no DEBT / TEST_DEBT +// entry for this package): +// +// 1. the CONTRACT half — what `IDataDriver` declares for each door; +// 2. the DRIVER half — the override is not `any` and resolves to exactly the +// contract's type. Putting any one annotation back to `Promise` reds +// this file twice for that door: `IsAny` flips to `true`, `Equals` to +// `false`. +// +// Reverse verification, direction predicted BEFORE it was run: with the four +// overrides at `Promise`, `pnpm --filter @objectstack/driver-turso +// typecheck` fails with TS2322 on the eight driver consts below (two per door) +// and on nothing else; `pnpm test` stays green either way — the type-level +// facts are carried by consts vitest only compares. +// +// `explain()` is deliberately absent from the driver half: `TursoDriver` does +// NOT override it, so this package has no second declaration of that door to +// pin — it reaches these consumers through `@objectstack/driver-sql`'s `.d.ts`, +// where `sql-driver-doors-declared-types.test.ts` pins it. `upsert()`, +// `aggregate()` and `beginTransaction()` are out of this card's scope by +// ruling and are not asserted here; their annotations did not move. +// +// The runtime cases below drive the LOCAL face (`:memory:`); the remote face's +// shapes are pinned by the `RemoteTransport` suites. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { IDataDriver } from '@objectstack/spec/contracts'; +import { TursoDriver } from './turso-driver.js'; + +/** `any` defeats ordinary assignability checks; this is the standard detector. */ +type IsAny = 0 extends 1 & T ? true : false; +/** Exact (mutual, non-`any`) type equality. */ +type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +type Resolved = F extends (...args: never[]) => PromiseLike ? R : never; + +type ContractFindOne = Resolved; +type ContractCreate = Resolved; +type ContractBulkCreate = Resolved; +type ContractExecute = Resolved; + +type TursoFindOne = Resolved; +type TursoCreate = Resolved; +type TursoBulkCreate = Resolved; +type TursoExecute = Resolved; + +// 1. The contract half — what `IDataDriver` already declared before this change. +const contractFindOne: Equals | null> = true; +const contractCreate: Equals> = true; +const contractBulkCreate: Equals[]> = true; +const contractExecute: Equals = true; + +// 2. The driver half — each override un-masked, reading exactly as the +// contract reads. `execute` needs the `IsAny` leg most of all: +// `Equals` is already `false`, so without it a door that +// regressed to `any` would be reported only as "not `unknown`". +const tursoFindOneIsAny: IsAny = false; +const tursoFindOneIsContract: Equals | null> = true; +const tursoCreateIsAny: IsAny = false; +const tursoCreateIsContract: Equals> = true; +const tursoBulkCreateIsAny: IsAny = false; +const tursoBulkCreateIsContract: Equals[]> = true; +const tursoExecuteIsAny: IsAny = false; +const tursoExecuteIsContract: Equals = true; + +/** + * The slice of the inherited (protected) Knex instance this fixture touches. + * `knex` is not a dependency of this package, so its types are not imported; + * naming the members reached keeps a file about "the door is no longer `any`" + * free of `any` itself. + */ +type TableBuilder = { string(name: string): { primary(): unknown } }; +type KnexSlice = { + schema: { createTable(name: string, build: (t: TableBuilder) => void): Promise }; +} & ((table: string) => { insert(row: Record): Promise }); + +describe('TursoDriver declared return types on the doors it overrides (#15267)', () => { + let driver: TursoDriver; + + beforeEach(async () => { + driver = new TursoDriver({ url: ':memory:' }); + const k = (driver as unknown as { knex: KnexSlice }).knex; + await k.schema.createTable('t', (t) => { + t.string('id').primary(); + t.string('name'); + }); + await k('t').insert({ id: '1', name: 'before' }); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('pins the contract half of the four overridden doors', () => { + expect([contractFindOne, contractCreate, contractBulkCreate, contractExecute]).toEqual([true, true, true, true]); + }); + + it('pins the driver half: no override is `any`, each is the contract type', () => { + expect([tursoFindOneIsAny, tursoCreateIsAny, tursoBulkCreateIsAny, tursoExecuteIsAny]).toEqual([ + false, + false, + false, + false, + ]); + expect([ + tursoFindOneIsContract, + tursoCreateIsContract, + tursoBulkCreateIsContract, + tursoExecuteIsContract, + ]).toEqual([true, true, true, true]); + }); + + it('findOne() on a query that matches nothing resolves to null on the local face, and the declared type makes the caller narrow', async () => { + const result = await driver.findOne('t', { where: { id: 'missing' } }); + expect(result).toBeNull(); + + // The narrowing the declared type now demands of every caller. + const name = result === null ? 'absent' : result.name; + expect(name).toBe('absent'); + }); + + it('findOne() on a match resolves to the row, behind the same narrowing', async () => { + const result = await driver.findOne('t', { where: { id: '1' } }); + expect(result).not.toBeNull(); + expect(result === null ? 'absent' : result.name).toBe('before'); + }); + + it('create() and bulkCreate() resolve to record shapes the declared types describe', async () => { + const created = await driver.create('t', { id: '2', name: 'via create' }); + expect(created.id).toBe('2'); + + const batch = await driver.bulkCreate('t', [ + { id: '3', name: 'a' }, + { id: '4', name: 'b' }, + ]); + expect(batch).toHaveLength(2); + expect(batch.map((row) => row.id)).toEqual(['3', '4']); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 7527c78a1a..90e39b5b0d 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -957,7 +957,13 @@ export class TursoDriver extends SqlDriver { return super.find(object, query, options); } - override async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise { + // [#15267] The override declares the contract's type, as both of its branches + // already do: `RemoteTransport.findOne()` answers + // `Record | null` and `formatRemoteRow` is a generic + // pass-through; the local branch forwards to `super.findOne` (narrowed + // alongside). The explicit `Promise` was this package's own `.d.ts` + // re-erasing the door, which no driver-sql fix reaches. + override async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.findOne(object, this.toRemoteReadQuery(object, query, { singleRowLookup: true }))); return super.findOne(object, query, options); } @@ -1077,7 +1083,12 @@ export class TursoDriver extends SqlDriver { ); } - override async create(object: string, data: Record, options?: DriverOptions): Promise { + // [#15267] The override declares the contract's type, as both of its branches + // already do: `RemoteTransport.create()` answers `Record` + // through the generic `formatRemoteRow`, and the local branch forwards to + // `super.create` (narrowed alongside). Same shape the `update()` override + // above took with #14438. + override async create(object: string, data: Record, options?: DriverOptions): Promise> { if (this.isRemote) { this.refuseUngeneratableRemoteAutonumber(object, [data], 'create'); return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data))); @@ -1554,7 +1565,11 @@ export class TursoDriver extends SqlDriver { // Bulk Operations (remote mode overrides) // =================================== - override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise { + // [#15267] The override declares the contract's type: `RemoteTransport + // .bulkCreate()` answers `Record[]` through the generic + // `formatRemoteRows`, and the local branch forwards to `super.bulkCreate` + // (narrowed alongside). + override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { if (this.isRemote) { // [#6944] Same refusal as `create`, and it has to be stated here rather // than inherited: `RemoteTransport.bulkCreate` loops its OWN `create`, not @@ -1600,7 +1615,11 @@ export class TursoDriver extends SqlDriver { // Raw Execution (remote mode override) // =================================== - override async execute(command: any, params?: any[], options?: DriverOptions): Promise { + // [#15267] The override declares the contract's `unknown`: + // `RemoteTransport.execute()` already answers `unknown`, and the local branch + // forwards to `super.execute` (narrowed alongside). The explicit + // `Promise` erased the contract's `unknown` on this package's `.d.ts`. + override async execute(command: any, params?: any[], options?: DriverOptions): Promise { if (this.isRemote) { // [#16019] The remote transport hands the libsql client's error back // whole — `SQLITE_ERROR: no such function: translate`: no statement, no From 0f3c8b22e9ca8ea5602b6e4837e0465763ddd85f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:12:26 +0000 Subject: [PATCH 2/6] fix(driver-sql,driver-turso): narrow the consumer sites the declared doors surfaced (#15267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/sql-driver-11389-date-tz-skew.test.ts | 5 ++++- .../sql-driver-13973-canonical-iso-read-door.test.ts | 5 ++++- .../driver-sql/src/sql-driver-advanced.test.ts | 6 +++++- .../driver-sql/src/sql-driver-array-fields.test.ts | 5 ++++- .../src/sql-driver-autonumber-batch-resync.test.ts | 4 +++- .../driver-sql/src/sql-driver-bulk-json.test.ts | 3 ++- .../driver-sql/src/sql-driver-date-only.test.ts | 12 ++++++++++-- .../sql-driver-json-column-operator-refusal.test.ts | 3 ++- .../src/sql-driver-numeric-fidelity.test.ts | 8 +++++++- .../src/sql-driver-tenant-scope-read-doors.test.ts | 9 ++++++++- .../driver-sql/src/sql-driver-tenant-scope.test.ts | 6 +++++- packages/drivers/driver-sql/src/sql-driver.test.ts | 5 ++++- .../driver-turso/src/remote-read-coercion.test.ts | 8 ++++++-- 13 files changed, 64 insertions(+), 15 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts index 8673908336..3b446298bd 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts @@ -36,7 +36,7 @@ * swept underneath them — the card's own end-to-end table, executed. */ -import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, describe, expect, it, assert } from 'vitest'; import { SqlDriver } from './sql-driver.js'; import { MYSQL_CELL, @@ -335,6 +335,7 @@ describe('#11389 — the write and filter paths keep reading a Date on the UTC c await driver.create('deal', { id: 'd1', close_date: new Date(iso) }, { bypassTenantAudit: true }); const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.close_date).toBe(expected); // The filter path takes the same helper, so it has to agree — a @@ -396,7 +397,9 @@ function declareZoneSweep(cell: DialectCell): void { const d = await connect(); await underProcessZone(tz, async () => { const day = await d.findOne(TABLE, { where: { id: 'r1' } }, { bypassTenantAudit: true }); + assert(day !== null, 'findOne answered the not-found arm for a seeded id'); const ny = await d.findOne(TABLE, { where: { id: 'r2' } }, { bypassTenantAudit: true }); + assert(ny !== null, 'findOne answered the not-found arm for a seeded id'); expect(day.close_date, `${cell.label} read the wrong calendar day under TZ=${tz}`).toBe(DAY); // A one-day skew here changes the YEAR, which is the most legible diff --git a/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts b/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts index 1142c0548b..2676ac6f9c 100644 --- a/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts @@ -66,7 +66,7 @@ * milliseconds — what `String(Date)` does — would be visible too. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, assert } from 'vitest'; import type { DriverQuery } from '@objectstack/spec/contracts'; import { SqlDriver } from './index.js'; import { @@ -224,6 +224,7 @@ function measure(cell: DialectCell): void { it('§A3 findOne(), and the rows update() and create() return, present the same shape', async () => { const one = await driver.findOne(TABLE, { where: { id: 'r1' } }, OPTS); + assert(one !== null, 'findOne answered the not-found arm for a seeded id'); expect(one, 'findOne returned nothing').toBeTruthy(); for (const col of INSTANT_COLUMNS) expectCanonicalInstant(one[col], `findOne ${col}`); expect(one.closed_at).toBe(CLOSED_AT[1]); @@ -267,6 +268,7 @@ function measure(cell: DialectCell): void { // (`readback.first()` → `formatOutput`), so its return is a whole row and // the guard applies unqualified. const before = await driver.findOne(TABLE_RETURNS, { where: { id: 'w0' } }, OPTS); + assert(before !== null, 'findOne answered the not-found arm for a seeded id'); expect(before, 'the seed row is missing').toBeTruthy(); const merged = await driver.upsert(TABLE_RETURNS, { id: 'w0', title: 'write row 0 (merged)' }, undefined, OPTS); const inserted = await driver.upsert( @@ -556,6 +558,7 @@ describe('#13973 §D — find(), distinct() and aggregate() present the audit co await driver.create(T_RAW, { id: 'x1', n: 2 }, OPTS); await (driver as any).knex(T_RAW).where('id', 'x1').update({ updated_at: '2026-01-10 09:00:00' }); const legacy = await driver.findOne(T_RAW, { where: { id: 'x1' } }, OPTS); + assert(legacy !== null, 'findOne answered the not-found arm for a seeded id'); expect(legacy.updated_at).toBe('2026-01-10T09:00:00.000Z'); const distinct = await driver.distinct(T_RAW, 'updated_at', undefined, OPTS); expect(distinct).toContain('2026-01-10T09:00:00.000Z'); diff --git a/packages/drivers/driver-sql/src/sql-driver-advanced.test.ts b/packages/drivers/driver-sql/src/sql-driver-advanced.test.ts index 49cb53ec0c..d61183a90d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-advanced.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-advanced.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; describe('SqlDriver Advanced Operations (SQLite)', () => { @@ -191,6 +191,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); const result = await driver.findOne('orders', { where: { id: 'trx1' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.customer).toBe('TxUser'); } catch (e) { @@ -253,6 +254,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { expect(created).toBeDefined(); const updated = await driver.findOne('orders', { where: { id: '1' } }); + assert(updated !== null, 'findOne answered the not-found arm for a seeded id'); expect(updated.status).toBe('shipped'); const deleted = await driver.findOne('orders', { where: { id: '5' } }); @@ -285,6 +287,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { await driver.create('nullable_test', { id: '1', name: null, value: null }); const result = await driver.findOne('nullable_test', { where: { id: '1' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.name).toBeNull(); expect(result.value).toBeNull(); @@ -360,6 +363,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => { it('should handle findOne with query parameter', async () => { const result = await driver.findOne('orders', { where: { customer: 'Charlie' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.customer).toBe('Charlie'); diff --git a/packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts b/packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts index eb030fe5e2..bafde3a6a0 100644 --- a/packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts @@ -15,7 +15,7 @@ * instead of a 500. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; describe('SqlDriver array/object field persistence', () => { @@ -64,6 +64,7 @@ describe('SqlDriver array/object field persistence', () => { { bypassTenantAudit: true }, ); const row = await driver.findOne('zoo', { where: { id: 'z1' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.tags).toEqual(['x', 'y']); expect(row.ms).toEqual(['red', 'green']); expect(row.cbs).toEqual(['email', 'push']); @@ -77,12 +78,14 @@ describe('SqlDriver array/object field persistence', () => { await driver.create('zoo', { id: 'z2', name: 'B', tags: ['a'] }, { bypassTenantAudit: true }); await driver.update('zoo', 'z2', { tags: ['a', 'b', 'c'] }, { bypassTenantAudit: true }); const row = await driver.findOne('zoo', { where: { id: 'z2' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.tags).toEqual(['a', 'b', 'c']); }); it('does not crash on an empty array', async () => { await driver.create('zoo', { id: 'z3', name: 'C', ms: [] }, { bypassTenantAudit: true }); const row = await driver.findOne('zoo', { where: { id: 'z3' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.ms).toEqual([]); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-autonumber-batch-resync.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-batch-resync.test.ts index 67aade25d5..e971978bc5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-autonumber-batch-resync.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-autonumber-batch-resync.test.ts @@ -154,7 +154,9 @@ describe('[#6943] batch and upsert re-seed a stale autonumber counter', () => { const numbers = await allNumbers(); expect(new Set(numbers).size).toBe(numbers.length); // no duplicate anywhere in the table // Every row of the batch sits above the seeded range it straddled. - for (const r of created) expect(Number(r.case_number.slice('CASE-'.length))).toBeGreaterThan(39); + // [#15267] `bulkCreate()` resolves to `Record[]` now, so the + // record number is read as the string it is rather than off an `any`. + for (const r of created) expect(Number(String(r.case_number).slice('CASE-'.length))).toBeGreaterThan(39); }); it('re-seeds only the counter that went stale, leaving a co-tenant in the same batch alone', async () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts b/packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts index 14ed9b13a7..ac62d09698 100644 --- a/packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; /** @@ -51,6 +51,7 @@ describe('SqlDriver bulkCreate JSON marshaling (#2735)', () => { // Read-back parity: JSON columns decode to objects, same as single insert. const v1 = await driver.findOne('venue', { where: { id: 'v1' } }); + assert(v1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(v1.location).toEqual({ lat: 47.6062, lng: -122.3321 }); expect(v1.tags).toEqual(['a', 'b']); expect(v1.meta).toEqual({ tier: 1 }); diff --git a/packages/drivers/driver-sql/src/sql-driver-date-only.test.ts b/packages/drivers/driver-sql/src/sql-driver-date-only.test.ts index ae84410e56..fc5753dd38 100644 --- a/packages/drivers/driver-sql/src/sql-driver-date-only.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-date-only.test.ts @@ -13,7 +13,7 @@ * behaviour and guard that `Field.datetime` keeps its full-instant meaning. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; 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)', ( { bypassTenantAudit: true }, ); const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.close_date).toBe('2026-07-15'); }); @@ -60,6 +61,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { bypassTenantAudit: true }, ); const row = await driver.findOne('deal', { where: { id: 'd2' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.close_date).toBe('2026-07-15'); }); @@ -70,6 +72,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { bypassTenantAudit: true }, ); const row = await driver.findOne('deal', { where: { id: 'd3' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.close_date).toBe('2026-07-15'); }); @@ -80,8 +83,12 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', ( { bypassTenantAudit: true }, ); const row = await driver.findOne('deal', { where: { id: 'd4' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); // datetime must retain its wall-clock time — never sliced to YYYY-MM-DD. - expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z'); + // [#15267] `findOne()` resolves to `Record` now — the + // stored instant comes back as a `Date` on some clients and an ISO string + // on others, which is exactly the union `Date` accepts. + expect(new Date(row.signed_at as string | number | Date).toISOString()).toBe('2026-03-20T12:34:56.000Z'); }); 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)', ( // Read-side repair: the returned value is date-only with no migration. const row = await driver.findOne('deal', { where: { id: 'legacy' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.close_date).toBe('2026-08-15'); // …but the value still stored in SQL keeps its time, so a SQL equality diff --git a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts index 70e97d3679..9bee226f22 100644 --- a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts @@ -59,7 +59,7 @@ * `$containsAny`), which would open the closed `FILTER_OPERATORS` set. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; import { FILTER_OPERATORS, markFilterSubtreeProvenance } from '@objectstack/spec/data'; import type { FilterCondition } from '@objectstack/spec/data'; @@ -386,6 +386,7 @@ describe('[#7398] SqlDriver refuses scalar-comparison operators on JSON/multi-va it('the write faces really did not write — a refusal is not a half-applied mutation', async () => { const row = await driver.findOne('team', { where: { id: 't1' } }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.name).toBe('Alpha'); expect(await driver.count('team', {})).toBe(1); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts b/packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts index 410670d50b..e41e29bc85 100644 --- a/packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts @@ -19,7 +19,7 @@ * the SAME harness must keep returning them with correct types. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => { @@ -76,6 +76,7 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => { bypassTenantAudit: true }, ); const row = await driver.findOne('zoo', { where: { id: 'z1' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); // control expect(row.f_number).toBe(42); @@ -96,7 +97,9 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => await driver.create('zoo', { id: 'z3', name: 'C', f_boolean: false, f_toggle: false }, { bypassTenantAudit: true }); const on = await driver.findOne('zoo', { where: { id: 'z2' } }, { bypassTenantAudit: true }); + assert(on !== null, 'findOne answered the not-found arm for a seeded id'); const off = await driver.findOne('zoo', { where: { id: 'z3' } }, { bypassTenantAudit: true }); + assert(off !== null, 'findOne answered the not-found arm for a seeded id'); // control expect(on.f_boolean).toBe(true); @@ -122,6 +125,7 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => { bypassTenantAudit: true }, ); const row = await driver.findOne('zoo', { where: { id: 'z4' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.f_record).toEqual({ home: '+1', work: '+2' }); expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 }); @@ -188,6 +192,7 @@ describe('SqlDriver numeric read coercion repairs legacy TEXT columns', () => { { bypassTenantAudit: true }, ); const row = await driver.findOne('legacy', { where: { id: 'L1' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(typeof row.f_rating).toBe('number'); expect(row.f_rating).toBe(4); @@ -203,6 +208,7 @@ describe('SqlDriver numeric read coercion repairs legacy TEXT columns', () => { // TEXT columns, bypassing the driver, to model messy legacy data. await knex('legacy').insert({ id: 'L2', name: 'messy', f_rating: null, f_slider: 'n/a', f_progress: '60' }); const row = await driver.findOne('legacy', { where: { id: 'L2' } }, { bypassTenantAudit: true }); + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.f_rating).toBeNull(); // null stays null, not 0 expect(row.f_slider).toBe('n/a'); // non-numeric junk is preserved, not NaN diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts index cad767cd5a..8777370c73 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts @@ -300,7 +300,14 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792) }); it('reaches the same statement through `explain()`, which forwards here', async () => { - const explained = await driver.explain(TABLE, {} as any, { tenantId: 'org_a' } as any); + // [#15267] `explain()` declares the contract's `unknown` now, so this + // caller names the slice of the plan it reads instead of reaching through + // an `any`. The two members below are `analyzeQuery()`'s on every arm it + // can return (statement, bindings, and then the client-specific plan). + const explained = (await driver.explain(TABLE, {} as any, { tenantId: 'org_a' } as any)) as { + sql: string; + bindings: unknown[]; + }; expect(explained.sql).toContain('organization_id'); expect(explained.bindings).toContain('org_a'); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts index 40dd1d70b9..f16af70816 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { ObjectSchema } from '@objectstack/spec/data'; import { SqlDriver } from '../src/index.js'; @@ -76,12 +76,14 @@ describe('SqlDriver tenant scope (organization_id)', () => { // org_b tries to update org_a's a1 → no-op await driver.update('account', 'a1', { tier: 'compromised' }, { tenantId: 'org_b' }); const a1 = await driver.findOne('account', { where: { id: 'a1' } }); + assert(a1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(a1.tier).toBe('gold'); }); it('updates own rows fine', async () => { await driver.update('account', 'a1', { tier: 'platinum' }, { tenantId: 'org_a' }); const a1 = await driver.findOne('account', { where: { id: 'a1' } }); + assert(a1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(a1.tier).toBe('platinum'); }); }); @@ -491,11 +493,13 @@ describe('SqlDriver tenant scope (organization_id)', () => { const unionOpts = { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any; await driver.update('account', 'b1', { tier: 'platinum' }, unionOpts); const b1 = await driver.findOne('account', { where: { id: 'b1' } }); + assert(b1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(b1.tier).toBe('platinum'); // A tenant OUTSIDE the set stays untouchable — the widened wall still walls. await driver.create('account', { id: 'c1', organization_id: 'org_c', name: 'C1', tier: 'gold' }); await driver.update('account', 'c1', { tier: 'compromised' }, unionOpts); const c1 = await driver.findOne('account', { where: { id: 'c1' } }); + assert(c1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(c1.tier).toBe('gold'); }); diff --git a/packages/drivers/driver-sql/src/sql-driver.test.ts b/packages/drivers/driver-sql/src/sql-driver.test.ts index 14b79554c0..9c72351458 100644 --- a/packages/drivers/driver-sql/src/sql-driver.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqlDriver } from '../src/index.js'; describe('SqlDriver (SQLite Integration)', () => { @@ -85,6 +85,7 @@ describe('SqlDriver (SQLite Integration)', () => { expect(alice).toBeDefined(); const fetched = await driver.findOne('users', { where: { id: alice.id } }); + assert(fetched !== null, 'findOne answered the not-found arm for a seeded id'); expect(fetched).toBeDefined(); expect(fetched.name).toBe('Alice'); }); @@ -102,6 +103,7 @@ describe('SqlDriver (SQLite Integration)', () => { await driver.update('users', bob.id, { age: 18 }); const updated = await driver.findOne('users', { where: { id: bob.id } }); + assert(updated !== null, 'findOne answered the not-found arm for a seeded id'); expect(updated.age).toBe(18); }); @@ -123,6 +125,7 @@ describe('SqlDriver (SQLite Integration)', () => { expect(created.id).toBe('custom-id'); const fetched = await driver.findOne('users', { where: { id: 'custom-id' } }); + assert(fetched !== null, 'findOne answered the not-found arm for a seeded id'); expect(fetched).toBeDefined(); expect(fetched.name).toBe('Frank'); }); diff --git a/packages/drivers/driver-turso/src/remote-read-coercion.test.ts b/packages/drivers/driver-turso/src/remote-read-coercion.test.ts index 0e4d2694f3..6e3bde0d86 100644 --- a/packages/drivers/driver-turso/src/remote-read-coercion.test.ts +++ b/packages/drivers/driver-turso/src/remote-read-coercion.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, assert } from 'vitest'; import { TursoDriver } from './turso-driver.js'; /** @@ -88,7 +88,11 @@ describe('TursoDriver remote read coercion', () => { it('findOne() applies the same coercion', async () => { const driver = await makeRemoteDriver(); const row = await driver.findOne('widgets', { where: { id: '1' } }); - expect(row).not.toBeNull(); + // [#15267] `findOne()` declares its not-found arm now, and + // `expect(...).not.toBeNull()` is not a narrowing assertion — this is the + // positive control, so it asserts the row arm before reading it (a + // narrowing assertion, not a `!` and not a cast). + assert(row !== null, 'findOne answered the not-found arm for a seeded id'); expect(row.active).toBe(true); expect(row.meta).toEqual({ k: 1 }); }); From 1568c3b20b294d46e16f85406f2b5fe8f7945b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:13:39 +0000 Subject: [PATCH 3/6] 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 --- .../src/sqlite-wasm-driver-advanced.test.ts | 6 +++++- .../src/sqlite-wasm-driver-tenant-scope.test.ts | 4 +++- .../src/sqlite-wasm-driver-transaction-persist.test.ts | 4 +++- .../driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts | 4 +++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts index 1f3d047de9..de1e47e470 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqliteWasmDriver } from '../src/index.js'; describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { @@ -187,6 +187,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.commitTransaction(trx); const result = await driver.findOne('orders', { where: { id: 'trx1' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.customer).toBe('TxUser'); } catch (e) { @@ -249,6 +250,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { expect(created).toBeDefined(); const updated = await driver.findOne('orders', { where: { id: '1' } }); + assert(updated !== null, 'findOne answered the not-found arm for a seeded id'); expect(updated.status).toBe('shipped'); const deleted = await driver.findOne('orders', { where: { id: '5' } }); @@ -281,6 +283,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { await driver.create('nullable_test', { id: '1', name: null, value: null }); const result = await driver.findOne('nullable_test', { where: { id: '1' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.name).toBeNull(); expect(result.value).toBeNull(); @@ -349,6 +352,7 @@ describe('SqliteWasmDriver Advanced Operations (SQLite)', () => { it('should handle findOne with query parameter', async () => { const result = await driver.findOne('orders', { where: { customer: 'Charlie' } }); + assert(result !== null, 'findOne answered the not-found arm for a seeded id'); expect(result).toBeDefined(); expect(result.customer).toBe('Charlie'); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index 0988e01b66..c27c0e3f4c 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { SqliteWasmDriver } from '../src/index.js'; /** @@ -71,12 +71,14 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { // org_b tries to update org_a's a1 → no-op await driver.update('account', 'a1', { tier: 'compromised' }, { tenantId: 'org_b' }); const a1 = await driver.findOne('account', { where: { id: 'a1' } }); + assert(a1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(a1.tier).toBe('gold'); }); it('updates own rows fine', async () => { await driver.update('account', 'a1', { tier: 'platinum' }, { tenantId: 'org_a' }); const a1 = await driver.findOne('account', { where: { id: 'a1' } }); + assert(a1 !== null, 'findOne answered the not-found arm for a seeded id'); expect(a1.tier).toBe('platinum'); }); }); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts index 2e3483dc87..d489df08ab 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts @@ -91,7 +91,9 @@ describe('SqliteWasmDriver on-write persistence + transactions (#1494)', () => { const out: string[] = []; for (let i = 0; i < 10; i++) { const r = await driver.create('acct', { name: `R${i}` }); - out.push(r.num); + // [#15267] `create()` resolves to `Record` now, so the + // record number is read as the string this array collects. + out.push(String(r.num)); } expect(out[0]).toBe('A-0001'); expect(out[9]).toBe('A-0010'); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts index 9c8bcccc71..95e5ee7f85 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -61,6 +61,7 @@ describe('SqliteWasmDriver (in-memory)', () => { const [alice] = await driver.find('users', { where: { name: 'Alice' } }); expect(alice).toBeDefined(); const fetched = await driver.findOne('users', { where: { id: alice.id } }); + assert(fetched !== null, 'findOne answered the not-found arm for a seeded id'); expect(fetched.name).toBe('Alice'); }); @@ -74,6 +75,7 @@ describe('SqliteWasmDriver (in-memory)', () => { const [bob] = await driver.find('users', { where: { name: 'Bob' } }); await driver.update('users', bob.id, { age: 18 }); const updated = await driver.findOne('users', { where: { id: bob.id } }); + assert(updated !== null, 'findOne answered the not-found arm for a seeded id'); expect(updated.age).toBe(18); }); From cb1bd40110e7bf04543d3c2fe775db02c00180d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:22:39 +0000 Subject: [PATCH 4/6] fix(runtime): read the record number as a string in the autonumber parity probe (#15267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SqlDriver.create()` declares the contract's `Record` 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 --- .../autonumber-seed-cross-side-parity.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts b/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts index e306050068..b834dd8a06 100644 --- a/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts +++ b/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts @@ -144,7 +144,10 @@ async function sqlDriverIssues(format: string | undefined, stored: string[]): Pr const created = await driver.create('rec', { title: 'next' }); await driver.disconnect(); - return created.rec_no; + // [#15267] `SqlDriver.create()` declares the contract's + // `Record` now, so the record number is read as the string + // this parity probe compares — it was reached through an `any` before. + return String(created.rec_no); } interface Fixture { From b56fb0a32acfda294fe2d86096951da704805220 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:40:29 +0000 Subject: [PATCH 5/6] 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 --- .changeset/driver-sql-doors-declared-types.md | 15 +++++++++++++++ .changeset/driver-turso-doors-declared-types.md | 13 +++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/driver-sql-doors-declared-types.md create mode 100644 .changeset/driver-turso-doors-declared-types.md diff --git a/.changeset/driver-sql-doors-declared-types.md b/.changeset/driver-sql-doors-declared-types.md new file mode 100644 index 0000000000..6caae66a64 --- /dev/null +++ b/.changeset/driver-sql-doors-declared-types.md @@ -0,0 +1,15 @@ +--- +'@objectstack/driver-sql': minor +--- + +feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) + +**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` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `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. + +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. + +`@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. + +Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. + + diff --git a/.changeset/driver-turso-doors-declared-types.md b/.changeset/driver-turso-doors-declared-types.md new file mode 100644 index 0000000000..9392f11a20 --- /dev/null +++ b/.changeset/driver-turso-doors-declared-types.md @@ -0,0 +1,13 @@ +--- +'@objectstack/driver-turso': minor +--- + +feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) + +**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`. 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()`. + +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 | null`, `Record`, `Record[]` 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. + +`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. + + From 07a0244496587000f6ddeccd2d9ddd356b2e7aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:42:08 +0000 Subject: [PATCH 6/6] chore(changeset): name only predicate-4-verifiable symbols in the ADR-0087 markers (#15267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .changeset/driver-sql-doors-declared-types.md | 2 +- .changeset/driver-turso-doors-declared-types.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/driver-sql-doors-declared-types.md b/.changeset/driver-sql-doors-declared-types.md index 6caae66a64..58454d1397 100644 --- a/.changeset/driver-sql-doors-declared-types.md +++ b/.changeset/driver-sql-doors-declared-types.md @@ -12,4 +12,4 @@ Each door is now declared as the contract declares it. A caller that read fields Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. - + diff --git a/.changeset/driver-turso-doors-declared-types.md b/.changeset/driver-turso-doors-declared-types.md index 9392f11a20..921554eee0 100644 --- a/.changeset/driver-turso-doors-declared-types.md +++ b/.changeset/driver-turso-doors-declared-types.md @@ -10,4 +10,4 @@ Both branches of every one of the four already answered the contract's type: the `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. - +