Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/turso-driver-options-driveroptions.md
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 136 additions & 0 deletions packages/drivers/driver-turso/src/turso-driver-options-door.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>` 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<T> = 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<T> = IsAny<T> 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<Parameters<TursoDriver['find']>[2]>,
findOne: 'DriverOptions' satisfies Door<Parameters<TursoDriver['findOne']>[2]>,
create: 'DriverOptions' satisfies Door<Parameters<TursoDriver['create']>[2]>,
update: 'DriverOptions' satisfies Door<Parameters<TursoDriver['update']>[3]>,
upsert: 'DriverOptions' satisfies Door<Parameters<TursoDriver['upsert']>[3]>,
delete: 'DriverOptions' satisfies Door<Parameters<TursoDriver['delete']>[2]>,
count: 'DriverOptions' satisfies Door<Parameters<TursoDriver['count']>[2]>,
aggregate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['aggregate']>[2]>,
// Bulk
bulkCreate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkCreate']>[2]>,
bulkUpdate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkUpdate']>[2]>,
bulkDelete: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkDelete']>[2]>,
updateMany: 'DriverOptions' satisfies Door<Parameters<TursoDriver['updateMany']>[3]>,
deleteMany: 'DriverOptions' satisfies Door<Parameters<TursoDriver['deleteMany']>[2]>,
// Raw execution
execute: 'DriverOptions' satisfies Door<Parameters<TursoDriver['execute']>[2]>,
// Schema
syncSchema: 'DriverOptions' satisfies Door<Parameters<TursoDriver['syncSchema']>[2]>,
syncSchemasBatch: 'DriverOptions' satisfies Door<Parameters<TursoDriver['syncSchemasBatch']>[1]>,
dropTable: 'DriverOptions' satisfies Door<Parameters<TursoDriver['dropTable']>[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<TursoDriver['update']>[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<TursoDriver['update']>[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<TursoDriver['count']>[2];
expect(asBase).toBeUndefined();
});
});
54 changes: 32 additions & 22 deletions packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -503,13 +504,23 @@ export class TursoDriver extends SqlDriver {
// ===================================
// CRUD (remote mode overrides)
// ===================================

override async find(object: string, query: DriverQuery, options?: any): Promise<any[]> {
//
// [#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<any[]> {
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<any> {
override async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise<any> {
if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.findOne(object, this.toRemoteReadQuery(object, query, { singleRowLookup: true })));
return super.findOne(object, query, options);
}
Expand All @@ -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<string, any>, options?: any): Promise<any> {
override async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<any> {
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<string, any>, options?: any): Promise<any> {
override async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any> {
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<string, any>, conflictKeys?: string[], options?: any): Promise<Record<string, any>> {
override async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, any>> {
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<boolean> {
override async delete(object: string, id: string | number, options?: DriverOptions): Promise<boolean> {
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<number> {
override async count(object: string, query?: DriverQuery, options?: DriverOptions): Promise<number> {
if (this.isRemote) return this.remoteTransport!.count(object, this.toRemoteQuery(object, query));
return super.count(object, query, options);
}
Expand All @@ -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<any> {
override async aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise<any> {
if (this.isRemote) return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query));
return super.aggregate(object, query, options);
}
Expand Down Expand Up @@ -957,15 +967,15 @@ export class TursoDriver extends SqlDriver {
// Bulk Operations (remote mode overrides)
// ===================================

override async bulkCreate(object: string, data: any[], options?: any): Promise<any> {
override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise<any> {
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));
}
return super.bulkCreate(object, data, options);
}

override async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record<string, any> }>, options?: any): Promise<Record<string, any>[]> {
override async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record<string, any> }>, options?: DriverOptions): Promise<Record<string, any>[]> {
if (this.isRemote) {
const formatted = Array.isArray(updates)
? updates.map((u) => ({ ...u, data: this.toRemoteWriteForms(object, u.data) }))
Expand All @@ -975,19 +985,19 @@ export class TursoDriver extends SqlDriver {
return super.bulkUpdate(object, updates, options);
}

override async bulkDelete(object: string, ids: Array<string | number>, options?: any): Promise<void> {
override async bulkDelete(object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
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<number> {
override async updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
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<number> {
override async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise<number> {
if (this.isRemote) return this.remoteTransport!.deleteMany(object, this.toRemoteQuery(object, query));
return super.deleteMany(object, query, options);
}
Expand All @@ -996,7 +1006,7 @@ export class TursoDriver extends SqlDriver {
// Raw Execution (remote mode override)
// ===================================

override async execute(command: any, params?: any[], options?: any): Promise<any> {
override async execute(command: any, params?: any[], options?: DriverOptions): Promise<any> {
if (this.isRemote) return this.remoteTransport!.execute(command, params);
return super.execute(command, params, options);
}
Expand Down Expand Up @@ -1024,7 +1034,7 @@ export class TursoDriver extends SqlDriver {
// Schema Management (remote mode overrides)
// ===================================

override async syncSchema(object: string, schema: unknown, options?: any): Promise<void> {
override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise<void> {
if (this.isRemote) {
await this.remoteTransport!.syncSchema(object, schema);
// See initObjects(): populate the read-coercion registries for remote mode.
Expand Down Expand Up @@ -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<void> {
async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise<void> {
if (this.isRemote) {
return this.remoteTransport!.syncSchemasBatch(schemas);
}
Expand All @@ -1094,7 +1104,7 @@ export class TursoDriver extends SqlDriver {
}
}

override async dropTable(object: string, options?: any): Promise<void> {
override async dropTable(object: string, options?: DriverOptions): Promise<void> {
if (this.isRemote) return this.remoteTransport!.dropTable(object);
return super.dropTable(object, options);
}
Expand Down
Loading