From 260fca01935cf03bf6268feaf190921320432663 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:50:01 +0000 Subject: [PATCH 1/2] fix(driver-turso): narrow every override's `options` from `any` to `DriverOptions` (#6402) --- .../drivers/driver-turso/src/turso-driver.ts | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index aa889a05e1..bef5c0e042 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -21,6 +21,7 @@ import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql'; import type { DriverQuery } from '@objectstack/spec/contracts'; +import type { DriverOptions } from '@objectstack/spec/data'; import type { Client } from '@libsql/client'; import { RemoteTransport } from './remote-transport.js'; import { @@ -503,13 +504,23 @@ export class TursoDriver extends SqlDriver { // =================================== // CRUD (remote mode overrides) // =================================== - - override async find(object: string, query: DriverQuery, options?: any): Promise { + // + // [#6402] Every `options` parameter in this file is a {@link DriverOptions}, + // matching `SqlDriver` / `IDataDriver` — the two faces of one driver may not + // declare one argument two ways. This was the last `any` axis left in the + // overrides: #5181 (PR #6076), #6075 (PR #6210) and #6212 each narrowed + // `query`, and each deliberately left `options` alone because it is a + // SEPARATE axis whose shape was verbatim-identical across all 17 overrides — + // narrowing one would have read as a verdict on the other sixteen. #6402 + // closed all 17 in one sweep, so there is no half-narrowed state to + // interpret. Keep it that way: a new override here declares `DriverOptions`. + + override async find(object: string, query: DriverQuery, options?: DriverOptions): Promise { if (this.isRemote) return this.formatRemoteRows(object, await this.remoteTransport!.find(object, this.toRemoteReadQuery(object, query))); return super.find(object, query, options); } - override async findOne(object: string, query: DriverQuery, options?: any): Promise { + override async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise { if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.findOne(object, this.toRemoteReadQuery(object, query, { singleRowLookup: true }))); return super.findOne(object, query, options); } @@ -520,27 +531,27 @@ export class TursoDriver extends SqlDriver { // yielding — the opposite of the memory guarantee it was declared for. This // override went with the base method; page `find()` with `limit`/`offset`. - override async create(object: string, data: Record, options?: any): Promise { + override async create(object: string, data: Record, options?: DriverOptions): Promise { if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data))); return super.create(object, data, options); } - override async update(object: string, id: string | number, data: Record, options?: any): Promise { + override async update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise { if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.update(object, id, this.toRemoteWriteForms(object, data))); return super.update(object, id, data, options); } - override async upsert(object: string, data: Record, conflictKeys?: string[], options?: any): Promise> { + override async upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys)); return super.upsert(object, data, conflictKeys, options); } - override async delete(object: string, id: string | number, options?: any): Promise { + override async delete(object: string, id: string | number, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.delete(object, id); return super.delete(object, id, options); } - override async count(object: string, query?: DriverQuery, options?: any): Promise { + override async count(object: string, query?: DriverQuery, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.count(object, this.toRemoteQuery(object, query)); return super.count(object, query, options); } @@ -550,12 +561,11 @@ export class TursoDriver extends SqlDriver { * `SqlDriver.aggregate` this forwards to — the two faces of one driver may not * declare one argument two ways. * - * `options` is deliberately left `any`: it is a SECOND axis, shared verbatim - * with the four overrides above it, and narrowing one of five mid-file would - * read as a decision about the others. #6210 left the same `options?: any` on - * `count` for the same reason. + * [#6402] `options` is a {@link DriverOptions} for the same reason, closed as + * one sweep across every override in this file rather than one method at a + * time — see the block comment above `find()`. */ - override async aggregate(object: string, query: DriverQuery, options?: any): Promise { + override async aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query)); return super.aggregate(object, query, options); } @@ -957,7 +967,7 @@ export class TursoDriver extends SqlDriver { // Bulk Operations (remote mode overrides) // =================================== - override async bulkCreate(object: string, data: any[], options?: any): Promise { + override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise { if (this.isRemote) { const formatted = Array.isArray(data) ? data.map((d) => this.toRemoteWriteForms(object, d)) : data; return this.formatRemoteRows(object, await this.remoteTransport!.bulkCreate(object, formatted)); @@ -965,7 +975,7 @@ export class TursoDriver extends SqlDriver { return super.bulkCreate(object, data, options); } - override async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: any): Promise[]> { + override async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { if (this.isRemote) { const formatted = Array.isArray(updates) ? updates.map((u) => ({ ...u, data: this.toRemoteWriteForms(object, u.data) })) @@ -975,19 +985,19 @@ export class TursoDriver extends SqlDriver { return super.bulkUpdate(object, updates, options); } - override async bulkDelete(object: string, ids: Array, options?: any): Promise { + override async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.bulkDelete(object, ids); return super.bulkDelete(object, ids, options); } - override async updateMany(object: string, query: DriverQuery, data: any, options?: any): Promise { + override async updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { if (this.isRemote) { return this.remoteTransport!.updateMany(object, this.toRemoteQuery(object, query), this.toRemoteWriteForms(object, data)); } return super.updateMany(object, query, data, options); } - override async deleteMany(object: string, query: DriverQuery, options?: any): Promise { + override async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.deleteMany(object, this.toRemoteQuery(object, query)); return super.deleteMany(object, query, options); } @@ -996,7 +1006,7 @@ export class TursoDriver extends SqlDriver { // Raw Execution (remote mode override) // =================================== - override async execute(command: any, params?: any[], options?: any): Promise { + override async execute(command: any, params?: any[], options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.execute(command, params); return super.execute(command, params, options); } @@ -1024,7 +1034,7 @@ export class TursoDriver extends SqlDriver { // Schema Management (remote mode overrides) // =================================== - override async syncSchema(object: string, schema: unknown, options?: any): Promise { + override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise { if (this.isRemote) { await this.remoteTransport!.syncSchema(object, schema); // See initObjects(): populate the read-coercion registries for remote mode. @@ -1084,7 +1094,7 @@ export class TursoDriver extends SqlDriver { * In local/replica mode, falls back to sequential `syncSchema()` calls * (Knex + better-sqlite3 is already local, so batching has no benefit). */ - async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: any): Promise { + async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise { if (this.isRemote) { return this.remoteTransport!.syncSchemasBatch(schemas); } @@ -1094,7 +1104,7 @@ export class TursoDriver extends SqlDriver { } } - override async dropTable(object: string, options?: any): Promise { + override async dropTable(object: string, options?: DriverOptions): Promise { if (this.isRemote) return this.remoteTransport!.dropTable(object); return super.dropTable(object, options); } From eb44c173cea7239ff0fa2db6ed0ea52743193ed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:00:43 +0000 Subject: [PATCH 2/2] test(driver-turso): pin all 17 `options` doors + changeset (#6402) --- .../turso-driver-options-driveroptions.md | 35 +++++ .../src/turso-driver-options-door.test.ts | 136 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 .changeset/turso-driver-options-driveroptions.md create mode 100644 packages/drivers/driver-turso/src/turso-driver-options-door.test.ts diff --git a/.changeset/turso-driver-options-driveroptions.md b/.changeset/turso-driver-options-driveroptions.md new file mode 100644 index 0000000000..adff9ce258 --- /dev/null +++ b/.changeset/turso-driver-options-driveroptions.md @@ -0,0 +1,35 @@ +--- +"@objectstack/driver-turso": patch +--- + +fix(driver-turso): narrow every override's `options` from `any` to `DriverOptions` (#6402) + +`TursoDriver` overrides 17 methods that take an `options` argument, and every one +of them declared `options?: any` while the base it forwards to (`SqlDriver`, and +behind it the `IDataDriver` contract) declared `DriverOptions`. The keys +`DriverOptions` names — `bypassTenantAudit`, `tenantId`, `transaction`, +`accessible_org_ids`, `skipCache`, `timeout`, … — were therefore unchecked at all +17 doors. + +The argument is #5181's, one axis over. An internal caller that misspells +`bypassTenantAudit` gets no runtime complaint: the typo'd key is simply never +read, the write proceeds unaudited, and nothing anywhere says so. `tsc` is the +only channel that ever objects, and `any` had switched it off. Nothing is known +to have gone wrong through this gap — it is closed because the door was open, not +because someone walked through it. + +**Why all 17 at once.** The shape was character-identical across every override, +so narrowing a subset would read to the next person as a *verdict* on the rest. +That is not hypothetical: #6075 (PR #6210) narrowed `count`'s `query` and +deliberately left its `options`, and #6212 batch B did the same on `aggregate` — +each leaving a comment saying so. Those comments are now discharged. The three +prior narrowings (#5181 / PR #6076, #6075 / PR #6210, #6212) each closed the +`query` axis; this closes the `options` axis, which had never been touched. + +**Consumer impact.** Annotation-only — no runtime behaviour changes, and the full +monorepo typecheck is unchanged at 125/125 green, so no caller in this repo was +passing an off-contract value. It is a `patch` rather than a docs-only change +because the narrowed signatures are public: a downstream TypeScript consumer +holding a `TursoDriver`-typed reference and passing an `options` value that is not +a `DriverOptions` will now see a compile error where it previously saw none. That +error is the point — the value was already being ignored by the driver. diff --git a/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts b/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts new file mode 100644 index 0000000000..63cac2a0f2 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#6402] Every `options` door on `TursoDriver` is a `DriverOptions`. + * + * # What was open + * + * `TursoDriver` overrides 17 methods that take an `options` argument, and every + * one of them declared it `options?: any` while the base it forwards to + * (`SqlDriver`, and behind it the `IDataDriver` contract) declared + * `DriverOptions`. The keys `DriverOptions` names — `bypassTenantAudit`, + * `tenantId`, `transaction`, `accessible_org_ids`, … — were therefore unchecked + * at all 17 doors. The argument is #5181's, verbatim, one axis over: an internal + * caller that misspells `bypassTenantAudit` has `tsc` as its ONLY warning + * channel, and `any` switched that channel off. + * + * # Why all 17 at once + * + * The shape was character-identical across every override, so narrowing a subset + * would read to the next person as a *verdict* on the rest. That is not + * hypothetical: #6075 (PR #6210) narrowed `count`'s `query` and deliberately left + * its `options`, and #6212 batch B did the same on `aggregate` — each leaving a + * comment saying so. This file is the pin for the sweep that closed all of them + * together, so no half-narrowed state exists to be misread. + * + * Note the issue that prompted this counted FIVE such overrides (`update`, + * `upsert`, `delete`, `count`, `aggregate`) — the CRUD block it was reading while + * #6212 was in flight. The measurement against `main` found 17: the bulk block, + * `execute`, and the schema block carry the identical shape. Narrowing the five + * would have reproduced, at larger scale, exactly the partial-narrowing the issue + * was filed to prevent. + * + * # The table below is the pin, and it is a compile-time one + * + * `Door` reports `'any'` for an `any` door and `'DriverOptions'` only for one + * that is exactly `DriverOptions | undefined`. Each row then `satisfies` it with + * `'DriverOptions'`, so putting any single signature back to `any` fails `tsc` on + * that row with `error TS1360: Type '"DriverOptions"' does not satisfy the + * expected type '"any"'` — naming the method that drifted. A mutual-`extends` + * check could not do this: `any` satisfies both directions and reports green. + * + * # Reverse verification — direction predicted BEFORE it was run, per channel + * + * Revert `turso-driver.ts` to `options?: any` and: + * + * - `pnpm typecheck` goes RED, one error per reverted signature, on the table row + * for that method — plus TS2578 `Unused '@ts-expect-error' directive` on the + * misspelling case below, since under `any` the typo compiles fine. + * - `pnpm test` stays GREEN. Every assertion here is a type-level fact carried by + * a string literal; vitest sees three passing string comparisons either way. + * That split is the point — this defect has no runtime face at all, which is + * why it went 17-for-17 unnoticed. + * + * Measured with all 17 reverted, as predicted: 18 typecheck errors — 17 × TS1360, + * one per row, plus the TS2578 at the misspelling case; `pnpm test` green at 4/4. + */ + +import { describe, it, expect } from 'vitest'; +import { TursoDriver } from './turso-driver.js'; +import type { DriverOptions } from '@objectstack/spec/data'; + +/** `any` defeats ordinary assignability checks; this is the standard detector. */ +type IsAny = 0 extends 1 & T ? true : false; + +/** + * Reports what a given `options` door actually is. `'any'` for a widened door, + * `'DriverOptions'` for one that matches the base contract exactly. + */ +type Door = IsAny extends true + ? 'any' + : [T] extends [DriverOptions | undefined] + ? [DriverOptions | undefined] extends [T] + ? 'DriverOptions' + : 'other' + : 'other'; + +/** + * One row per override, keyed by the method and the positional index of its + * `options` argument. Adding an override with a widened `options` and forgetting + * this table is caught by the exhaustiveness assertion at the end. + */ +const doors = { + // CRUD + find: 'DriverOptions' satisfies Door[2]>, + findOne: 'DriverOptions' satisfies Door[2]>, + create: 'DriverOptions' satisfies Door[2]>, + update: 'DriverOptions' satisfies Door[3]>, + upsert: 'DriverOptions' satisfies Door[3]>, + delete: 'DriverOptions' satisfies Door[2]>, + count: 'DriverOptions' satisfies Door[2]>, + aggregate: 'DriverOptions' satisfies Door[2]>, + // Bulk + bulkCreate: 'DriverOptions' satisfies Door[2]>, + bulkUpdate: 'DriverOptions' satisfies Door[2]>, + bulkDelete: 'DriverOptions' satisfies Door[2]>, + updateMany: 'DriverOptions' satisfies Door[3]>, + deleteMany: 'DriverOptions' satisfies Door[2]>, + // Raw execution + execute: 'DriverOptions' satisfies Door[2]>, + // Schema + syncSchema: 'DriverOptions' satisfies Door[2]>, + syncSchemasBatch: 'DriverOptions' satisfies Door[1]>, + dropTable: 'DriverOptions' satisfies Door[1]>, +} as const; + +describe('[#6402] TursoDriver `options` doors are DriverOptions, all 17 of them', () => { + it('pins every override — each row is a compile-time check, listed here so a drift names the method', () => { + // The assertion that matters already ran in `tsc`. This keeps the count + // honest: a row silently deleted to make a revert compile shows up here. + expect(Object.keys(doors)).toHaveLength(17); + expect(Object.values(doors).every((d) => d === 'DriverOptions')).toBe(true); + }); + + it('admits the declared keys — the pin is not green because nothing fits', () => { + const declared: Parameters[3] = { + bypassTenantAudit: true, + tenantId: 'org_1', + skipCache: true, + timeout: 5_000, + }; + expect(declared.tenantId).toBe('org_1'); + }); + + it('refuses the misspelling that motivated the narrowing', () => { + // @ts-expect-error [#6402] `bypassTenantAdit` is not a key of DriverOptions. + const typo: Parameters[3] = { bypassTenantAdit: true }; + // The typo'd write silently does nothing at runtime — which is the whole + // point: `tsc` above is the only channel that ever objects. + expect(Object.keys(typo!)).toEqual(['bypassTenantAdit']); + }); + + it('the door is the base contract, not a structural look-alike', () => { + const asBase: DriverOptions | undefined = undefined satisfies Parameters[2]; + expect(asBase).toBeUndefined(); + }); +});