Skip to content

Commit fa2d3b7

Browse files
os-zhuangclaude
andauthored
fix(driver-turso): narrow every override's options from any to DriverOptions (#6402) (#6755)
* fix(driver-turso): narrow every override's `options` from `any` to `DriverOptions` (#6402) * test(driver-turso): pin all 17 `options` doors + changeset (#6402) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a911cef commit fa2d3b7

3 files changed

Lines changed: 203 additions & 22 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/driver-turso": patch
3+
---
4+
5+
fix(driver-turso): narrow every override's `options` from `any` to `DriverOptions` (#6402)
6+
7+
`TursoDriver` overrides 17 methods that take an `options` argument, and every one
8+
of them declared `options?: any` while the base it forwards to (`SqlDriver`, and
9+
behind it the `IDataDriver` contract) declared `DriverOptions`. The keys
10+
`DriverOptions` names — `bypassTenantAudit`, `tenantId`, `transaction`,
11+
`accessible_org_ids`, `skipCache`, `timeout`, … — were therefore unchecked at all
12+
17 doors.
13+
14+
The argument is #5181's, one axis over. An internal caller that misspells
15+
`bypassTenantAudit` gets no runtime complaint: the typo'd key is simply never
16+
read, the write proceeds unaudited, and nothing anywhere says so. `tsc` is the
17+
only channel that ever objects, and `any` had switched it off. Nothing is known
18+
to have gone wrong through this gap — it is closed because the door was open, not
19+
because someone walked through it.
20+
21+
**Why all 17 at once.** The shape was character-identical across every override,
22+
so narrowing a subset would read to the next person as a *verdict* on the rest.
23+
That is not hypothetical: #6075 (PR #6210) narrowed `count`'s `query` and
24+
deliberately left its `options`, and #6212 batch B did the same on `aggregate`
25+
each leaving a comment saying so. Those comments are now discharged. The three
26+
prior narrowings (#5181 / PR #6076, #6075 / PR #6210, #6212) each closed the
27+
`query` axis; this closes the `options` axis, which had never been touched.
28+
29+
**Consumer impact.** Annotation-only — no runtime behaviour changes, and the full
30+
monorepo typecheck is unchanged at 125/125 green, so no caller in this repo was
31+
passing an off-contract value. It is a `patch` rather than a docs-only change
32+
because the narrowed signatures are public: a downstream TypeScript consumer
33+
holding a `TursoDriver`-typed reference and passing an `options` value that is not
34+
a `DriverOptions` will now see a compile error where it previously saw none. That
35+
error is the point — the value was already being ignored by the driver.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6402] Every `options` door on `TursoDriver` is a `DriverOptions`.
5+
*
6+
* # What was open
7+
*
8+
* `TursoDriver` overrides 17 methods that take an `options` argument, and every
9+
* one of them declared it `options?: any` while the base it forwards to
10+
* (`SqlDriver`, and behind it the `IDataDriver` contract) declared
11+
* `DriverOptions`. The keys `DriverOptions` names — `bypassTenantAudit`,
12+
* `tenantId`, `transaction`, `accessible_org_ids`, … — were therefore unchecked
13+
* at all 17 doors. The argument is #5181's, verbatim, one axis over: an internal
14+
* caller that misspells `bypassTenantAudit` has `tsc` as its ONLY warning
15+
* channel, and `any` switched that channel off.
16+
*
17+
* # Why all 17 at once
18+
*
19+
* The shape was character-identical across every override, so narrowing a subset
20+
* would read to the next person as a *verdict* on the rest. That is not
21+
* hypothetical: #6075 (PR #6210) narrowed `count`'s `query` and deliberately left
22+
* its `options`, and #6212 batch B did the same on `aggregate` — each leaving a
23+
* comment saying so. This file is the pin for the sweep that closed all of them
24+
* together, so no half-narrowed state exists to be misread.
25+
*
26+
* Note the issue that prompted this counted FIVE such overrides (`update`,
27+
* `upsert`, `delete`, `count`, `aggregate`) — the CRUD block it was reading while
28+
* #6212 was in flight. The measurement against `main` found 17: the bulk block,
29+
* `execute`, and the schema block carry the identical shape. Narrowing the five
30+
* would have reproduced, at larger scale, exactly the partial-narrowing the issue
31+
* was filed to prevent.
32+
*
33+
* # The table below is the pin, and it is a compile-time one
34+
*
35+
* `Door<T>` reports `'any'` for an `any` door and `'DriverOptions'` only for one
36+
* that is exactly `DriverOptions | undefined`. Each row then `satisfies` it with
37+
* `'DriverOptions'`, so putting any single signature back to `any` fails `tsc` on
38+
* that row with `error TS1360: Type '"DriverOptions"' does not satisfy the
39+
* expected type '"any"'` — naming the method that drifted. A mutual-`extends`
40+
* check could not do this: `any` satisfies both directions and reports green.
41+
*
42+
* # Reverse verification — direction predicted BEFORE it was run, per channel
43+
*
44+
* Revert `turso-driver.ts` to `options?: any` and:
45+
*
46+
* - `pnpm typecheck` goes RED, one error per reverted signature, on the table row
47+
* for that method — plus TS2578 `Unused '@ts-expect-error' directive` on the
48+
* misspelling case below, since under `any` the typo compiles fine.
49+
* - `pnpm test` stays GREEN. Every assertion here is a type-level fact carried by
50+
* a string literal; vitest sees three passing string comparisons either way.
51+
* That split is the point — this defect has no runtime face at all, which is
52+
* why it went 17-for-17 unnoticed.
53+
*
54+
* Measured with all 17 reverted, as predicted: 18 typecheck errors — 17 × TS1360,
55+
* one per row, plus the TS2578 at the misspelling case; `pnpm test` green at 4/4.
56+
*/
57+
58+
import { describe, it, expect } from 'vitest';
59+
import { TursoDriver } from './turso-driver.js';
60+
import type { DriverOptions } from '@objectstack/spec/data';
61+
62+
/** `any` defeats ordinary assignability checks; this is the standard detector. */
63+
type IsAny<T> = 0 extends 1 & T ? true : false;
64+
65+
/**
66+
* Reports what a given `options` door actually is. `'any'` for a widened door,
67+
* `'DriverOptions'` for one that matches the base contract exactly.
68+
*/
69+
type Door<T> = IsAny<T> extends true
70+
? 'any'
71+
: [T] extends [DriverOptions | undefined]
72+
? [DriverOptions | undefined] extends [T]
73+
? 'DriverOptions'
74+
: 'other'
75+
: 'other';
76+
77+
/**
78+
* One row per override, keyed by the method and the positional index of its
79+
* `options` argument. Adding an override with a widened `options` and forgetting
80+
* this table is caught by the exhaustiveness assertion at the end.
81+
*/
82+
const doors = {
83+
// CRUD
84+
find: 'DriverOptions' satisfies Door<Parameters<TursoDriver['find']>[2]>,
85+
findOne: 'DriverOptions' satisfies Door<Parameters<TursoDriver['findOne']>[2]>,
86+
create: 'DriverOptions' satisfies Door<Parameters<TursoDriver['create']>[2]>,
87+
update: 'DriverOptions' satisfies Door<Parameters<TursoDriver['update']>[3]>,
88+
upsert: 'DriverOptions' satisfies Door<Parameters<TursoDriver['upsert']>[3]>,
89+
delete: 'DriverOptions' satisfies Door<Parameters<TursoDriver['delete']>[2]>,
90+
count: 'DriverOptions' satisfies Door<Parameters<TursoDriver['count']>[2]>,
91+
aggregate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['aggregate']>[2]>,
92+
// Bulk
93+
bulkCreate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkCreate']>[2]>,
94+
bulkUpdate: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkUpdate']>[2]>,
95+
bulkDelete: 'DriverOptions' satisfies Door<Parameters<TursoDriver['bulkDelete']>[2]>,
96+
updateMany: 'DriverOptions' satisfies Door<Parameters<TursoDriver['updateMany']>[3]>,
97+
deleteMany: 'DriverOptions' satisfies Door<Parameters<TursoDriver['deleteMany']>[2]>,
98+
// Raw execution
99+
execute: 'DriverOptions' satisfies Door<Parameters<TursoDriver['execute']>[2]>,
100+
// Schema
101+
syncSchema: 'DriverOptions' satisfies Door<Parameters<TursoDriver['syncSchema']>[2]>,
102+
syncSchemasBatch: 'DriverOptions' satisfies Door<Parameters<TursoDriver['syncSchemasBatch']>[1]>,
103+
dropTable: 'DriverOptions' satisfies Door<Parameters<TursoDriver['dropTable']>[1]>,
104+
} as const;
105+
106+
describe('[#6402] TursoDriver `options` doors are DriverOptions, all 17 of them', () => {
107+
it('pins every override — each row is a compile-time check, listed here so a drift names the method', () => {
108+
// The assertion that matters already ran in `tsc`. This keeps the count
109+
// honest: a row silently deleted to make a revert compile shows up here.
110+
expect(Object.keys(doors)).toHaveLength(17);
111+
expect(Object.values(doors).every((d) => d === 'DriverOptions')).toBe(true);
112+
});
113+
114+
it('admits the declared keys — the pin is not green because nothing fits', () => {
115+
const declared: Parameters<TursoDriver['update']>[3] = {
116+
bypassTenantAudit: true,
117+
tenantId: 'org_1',
118+
skipCache: true,
119+
timeout: 5_000,
120+
};
121+
expect(declared.tenantId).toBe('org_1');
122+
});
123+
124+
it('refuses the misspelling that motivated the narrowing', () => {
125+
// @ts-expect-error [#6402] `bypassTenantAdit` is not a key of DriverOptions.
126+
const typo: Parameters<TursoDriver['update']>[3] = { bypassTenantAdit: true };
127+
// The typo'd write silently does nothing at runtime — which is the whole
128+
// point: `tsc` above is the only channel that ever objects.
129+
expect(Object.keys(typo!)).toEqual(['bypassTenantAdit']);
130+
});
131+
132+
it('the door is the base contract, not a structural look-alike', () => {
133+
const asBase: DriverOptions | undefined = undefined satisfies Parameters<TursoDriver['count']>[2];
134+
expect(asBase).toBeUndefined();
135+
});
136+
});

packages/drivers/driver-turso/src/turso-driver.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql';
2323
import type { DriverQuery } from '@objectstack/spec/contracts';
24+
import type { DriverOptions } from '@objectstack/spec/data';
2425
import type { Client } from '@libsql/client';
2526
import { RemoteTransport } from './remote-transport.js';
2627
import {
@@ -503,13 +504,23 @@ export class TursoDriver extends SqlDriver {
503504
// ===================================
504505
// CRUD (remote mode overrides)
505506
// ===================================
506-
507-
override async find(object: string, query: DriverQuery, options?: any): Promise<any[]> {
507+
//
508+
// [#6402] Every `options` parameter in this file is a {@link DriverOptions},
509+
// matching `SqlDriver` / `IDataDriver` — the two faces of one driver may not
510+
// declare one argument two ways. This was the last `any` axis left in the
511+
// overrides: #5181 (PR #6076), #6075 (PR #6210) and #6212 each narrowed
512+
// `query`, and each deliberately left `options` alone because it is a
513+
// SEPARATE axis whose shape was verbatim-identical across all 17 overrides —
514+
// narrowing one would have read as a verdict on the other sixteen. #6402
515+
// closed all 17 in one sweep, so there is no half-narrowed state to
516+
// interpret. Keep it that way: a new override here declares `DriverOptions`.
517+
518+
override async find(object: string, query: DriverQuery, options?: DriverOptions): Promise<any[]> {
508519
if (this.isRemote) return this.formatRemoteRows(object, await this.remoteTransport!.find(object, this.toRemoteReadQuery(object, query)));
509520
return super.find(object, query, options);
510521
}
511522

512-
override async findOne(object: string, query: DriverQuery, options?: any): Promise<any> {
523+
override async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise<any> {
513524
if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.findOne(object, this.toRemoteReadQuery(object, query, { singleRowLookup: true })));
514525
return super.findOne(object, query, options);
515526
}
@@ -520,27 +531,27 @@ export class TursoDriver extends SqlDriver {
520531
// yielding — the opposite of the memory guarantee it was declared for. This
521532
// override went with the base method; page `find()` with `limit`/`offset`.
522533

523-
override async create(object: string, data: Record<string, any>, options?: any): Promise<any> {
534+
override async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<any> {
524535
if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data)));
525536
return super.create(object, data, options);
526537
}
527538

528-
override async update(object: string, id: string | number, data: Record<string, any>, options?: any): Promise<any> {
539+
override async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any> {
529540
if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.update(object, id, this.toRemoteWriteForms(object, data)));
530541
return super.update(object, id, data, options);
531542
}
532543

533-
override async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: any): Promise<Record<string, any>> {
544+
override async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, any>> {
534545
if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys));
535546
return super.upsert(object, data, conflictKeys, options);
536547
}
537548

538-
override async delete(object: string, id: string | number, options?: any): Promise<boolean> {
549+
override async delete(object: string, id: string | number, options?: DriverOptions): Promise<boolean> {
539550
if (this.isRemote) return this.remoteTransport!.delete(object, id);
540551
return super.delete(object, id, options);
541552
}
542553

543-
override async count(object: string, query?: DriverQuery, options?: any): Promise<number> {
554+
override async count(object: string, query?: DriverQuery, options?: DriverOptions): Promise<number> {
544555
if (this.isRemote) return this.remoteTransport!.count(object, this.toRemoteQuery(object, query));
545556
return super.count(object, query, options);
546557
}
@@ -550,12 +561,11 @@ export class TursoDriver extends SqlDriver {
550561
* `SqlDriver.aggregate` this forwards to — the two faces of one driver may not
551562
* declare one argument two ways.
552563
*
553-
* `options` is deliberately left `any`: it is a SECOND axis, shared verbatim
554-
* with the four overrides above it, and narrowing one of five mid-file would
555-
* read as a decision about the others. #6210 left the same `options?: any` on
556-
* `count` for the same reason.
564+
* [#6402] `options` is a {@link DriverOptions} for the same reason, closed as
565+
* one sweep across every override in this file rather than one method at a
566+
* time — see the block comment above `find()`.
557567
*/
558-
override async aggregate(object: string, query: DriverQuery, options?: any): Promise<any> {
568+
override async aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise<any> {
559569
if (this.isRemote) return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query));
560570
return super.aggregate(object, query, options);
561571
}
@@ -957,15 +967,15 @@ export class TursoDriver extends SqlDriver {
957967
// Bulk Operations (remote mode overrides)
958968
// ===================================
959969

960-
override async bulkCreate(object: string, data: any[], options?: any): Promise<any> {
970+
override async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise<any> {
961971
if (this.isRemote) {
962972
const formatted = Array.isArray(data) ? data.map((d) => this.toRemoteWriteForms(object, d)) : data;
963973
return this.formatRemoteRows(object, await this.remoteTransport!.bulkCreate(object, formatted));
964974
}
965975
return super.bulkCreate(object, data, options);
966976
}
967977

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

978-
override async bulkDelete(object: string, ids: Array<string | number>, options?: any): Promise<void> {
988+
override async bulkDelete(object: string, ids: Array<string | number>, options?: DriverOptions): Promise<void> {
979989
if (this.isRemote) return this.remoteTransport!.bulkDelete(object, ids);
980990
return super.bulkDelete(object, ids, options);
981991
}
982992

983-
override async updateMany(object: string, query: DriverQuery, data: any, options?: any): Promise<number> {
993+
override async updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
984994
if (this.isRemote) {
985995
return this.remoteTransport!.updateMany(object, this.toRemoteQuery(object, query), this.toRemoteWriteForms(object, data));
986996
}
987997
return super.updateMany(object, query, data, options);
988998
}
989999

990-
override async deleteMany(object: string, query: DriverQuery, options?: any): Promise<number> {
1000+
override async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise<number> {
9911001
if (this.isRemote) return this.remoteTransport!.deleteMany(object, this.toRemoteQuery(object, query));
9921002
return super.deleteMany(object, query, options);
9931003
}
@@ -996,7 +1006,7 @@ export class TursoDriver extends SqlDriver {
9961006
// Raw Execution (remote mode override)
9971007
// ===================================
9981008

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

1027-
override async syncSchema(object: string, schema: unknown, options?: any): Promise<void> {
1037+
override async syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise<void> {
10281038
if (this.isRemote) {
10291039
await this.remoteTransport!.syncSchema(object, schema);
10301040
// See initObjects(): populate the read-coercion registries for remote mode.
@@ -1084,7 +1094,7 @@ export class TursoDriver extends SqlDriver {
10841094
* In local/replica mode, falls back to sequential `syncSchema()` calls
10851095
* (Knex + better-sqlite3 is already local, so batching has no benefit).
10861096
*/
1087-
async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: any): Promise<void> {
1097+
async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise<void> {
10881098
if (this.isRemote) {
10891099
return this.remoteTransport!.syncSchemasBatch(schemas);
10901100
}
@@ -1094,7 +1104,7 @@ export class TursoDriver extends SqlDriver {
10941104
}
10951105
}
10961106

1097-
override async dropTable(object: string, options?: any): Promise<void> {
1107+
override async dropTable(object: string, options?: DriverOptions): Promise<void> {
10981108
if (this.isRemote) return this.remoteTransport!.dropTable(object);
10991109
return super.dropTable(object, options);
11001110
}

0 commit comments

Comments
 (0)