From b2ac60025d82220969a6cc69cf595d6a352faba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:52:52 +0000 Subject: [PATCH 1/4] =?UTF-8?q?refactor(driver-sql)!:=20distinct=20?= =?UTF-8?q?=E7=9A=84=E7=AC=AC=E4=B8=89=E5=8F=82=E6=94=B6=E6=88=90=E8=A3=B8?= =?UTF-8?q?=20FilterCondition=20(#6320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SqlDriver.distinct` 不在 `IDataDriver` 上,所以 #5181 / #6075 的收窄没走到它, #6212 批 A+E(#6355)收的是 analyzeQuery / findWithWindowFunctions,也没覆盖它。 方法体一直说得很清楚 —— `applyFilters(builder, filters)` 拿的是实参本身,因此它 要的是 find() 放在 query.where 里的那个值,不是 query 信封;`filters?: any` 只是 没把这句话写进类型里。 实测(三行数据,逐个形状喂给 distinct): { status: 'completed' } RESOLVED ["Laptop","Mouse"] 省略 RESOLVED ["Laptop","Mouse","Ghost"] 'completed'(标量) RESOLVED ["Laptop","Mouse","Ghost"] <- 静默放宽 { object, where }(信封) THREW INVALID_FILTER / 400 ['status','=','completed'] THREW INVALID_FILTER / 400 (#5158) 第三行是本次消掉的那一格:applyFilters 对「真值但非对象、非数组」的 filter 不发射 任何谓词(尾注写着这件事),于是一个真心想按条件去重的调用编译通过并拿到全集。 有一格任何类型都关不上,如实写进注释而不是假装关上了:FilterCondition 的键就是 字段名,所以它是开放映射,`{ object, where }` 在结构上是合法 filter(约束两个分别 叫 object 和 where 的列)。#6320 提出的「让反向错配也编译不过」在这个参数上不可达, 实测确认;拿得到的保证是运行期响亮失败,已按行为 pin 钉住。driver-memory 那半边留在 #5499 冻结面内,本次不碰;aggregate 区(PR #6404 在飞)一行未动。 零运行时改动:非测试改动是一个类型注解加一段注释。 逐处复核了全部 14 个调用点(本单正文记 3 处,实测偏低):driver-sql 11、 driver-sqlite-wasm 3、driver-turso 0;真正传第三参的 4 处全部本来就写的裸 filter, 零报错、零 fixture 改动。 反向验证(先预判后跑,两次逐一相符): - 签名改回 any:driver-sql 3 红(1x TS2322 + 2x TS2578)、 driver-sqlite-wasm 2 红(1x TS2322 + 1x TS2578)。 - 往参数类型塞一个不可满足的成员并重建 driver-sql:driver-sql 6 红、 driver-sqlite-wasm 3 红 —— 证明 sqlite-wasm 确实读到了新构建的 dist/*.d.ts, 而不是陈旧副本。 Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-authored-by: Claude --- .../sql-distinct-bare-filter-condition.md | 43 +++++ ...l-driver-distinct-filter-narrowing.test.ts | 179 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 33 +++- ...ite-wasm-distinct-filter-narrowing.test.ts | 79 ++++++++ 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 .changeset/sql-distinct-bare-filter-condition.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts create mode 100644 packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts diff --git a/.changeset/sql-distinct-bare-filter-condition.md b/.changeset/sql-distinct-bare-filter-condition.md new file mode 100644 index 0000000000..0e6f1087fb --- /dev/null +++ b/.changeset/sql-distinct-bare-filter-condition.md @@ -0,0 +1,43 @@ +--- +"@objectstack/driver-sql": major +"@objectstack/driver-sqlite-wasm": major +--- + +refactor(driver-sql)!: `SqlDriver.distinct` 的第三参收成裸 `FilterCondition`,一个静默返回全集的写法就此编译不过 (#6320) + +`distinct` 不在 `IDataDriver` 上,所以 #5181(PR #6076)与 #6075(PR #6210)的收窄都没走到它,#6212 批 A+E(#6355)收的是 `analyzeQuery` / `findWithWindowFunctions`,也没覆盖它。它的方法体一直说得很清楚——`applyFilters(builder, filters)` 拿的是**实参本身**,因此它要的是 `find()` 放在 `query.where` 里的那个值,**不是 query 信封**;`filters?: any` 只是没把这句话写进类型里。 + +```ts +// 收窄前后都成立,一处调用点都不用改 +await driver.distinct('orders', 'product', { status: 'completed' }); +``` + +**收窄真正买到的东西,是实测出来的,不是推断的。** 三行数据(`Laptop`/`Mouse` 为 `completed`,`Ghost` 为 `pending`),逐个形状喂给 `distinct('orders','product', …)`: + +| 第三参 | 收窄前 | 收窄后 | +|:--|:--|:--| +| `{ status: 'completed' }` | 返回 `["Laptop","Mouse"]` | 不变 | +| 省略 | 返回全集 | 不变 | +| `'completed'`(标量) | **编译通过,返回全集** | **编译错误** | +| `{ object, where }`(信封) | 抛 `INVALID_FILTER` / 400 | 不变 | +| `['status','=','completed']` | 抛 `INVALID_FILTER` / 400(#5158) | 不变 | + +第三行就是本次消掉的那一格:一个真心想问「completed 订单里有哪些商品」的调用,编译通过,然后拿到**每一个**商品。`applyFilters` 对「真值但非对象、非数组」的 filter 不发射任何谓词(该方法尾注写着这件事),于是过滤条件被整条丢掉。方向是**放宽**——这正是 #6320 与 #5234 同族的那类「静默错答案」。 + +**有一格是任何类型都关不上的,本次如实写进注释而不是假装关上了。** `FilterCondition` 的键**就是字段名**,所以它是开放映射(`[key: string]: any`):`{ object, where }` 在结构上是一个完全合法的 filter——约束两个分别叫 `object` 和 `where` 的列。没有任何注解能把它和正当 filter 分开。#6320 提出的「让反向错配也编译不过」在这个参数上**不可达**,实测确认;能拿到的保证是**运行期响亮失败**:信封里的 `where` 是对象,而没有任何比较值可以是对象,于是 `assertCompilableComparand` 抛 `INVALID_FILTER` / 400。这半边 driver-sql 从来就不是静默的;`driver-memory` 那半边(裸 filter 交给它会静默返回全集)留在 #5499 冻结面内,本次不碰。 + +**零运行时改动**:非测试改动 100% 是一个类型注解加一段注释,无逻辑、无行为、无 emit 差异。 + +**逐处复核了全部 14 个调用点**(本单正文记的是 3 处,实测偏低):driver-sql 11 处、driver-sqlite-wasm 3 处、driver-turso 0 处;其中真正传第三参的是 4 处(driver-sql 2 + driver-sqlite-wasm 2),全部本来就写的裸 filter,**零报错、零 fixture 改动**。 + +**driver-sqlite-wasm 也标 major**:`SqliteWasmDriver extends SqlDriver` 且不覆写 `distinct`,所以它**已发布的 `.d.ts`** 里这个方法的签名同样收窄,它的使用者看到的是同一个变化。该包读的是 driver-sql 构建后的 `dist/*.d.ts` 而非源码,是一处已知门禁盲区,本次用「往参数类型里临时塞一个调用方不可能满足的成员、重建、看调用点是否逐一变红」证明它确实读到了新 d.ts:driver-sql 6 处红、driver-sqlite-wasm 3 处红,与预判逐一相符。 + +### 迁移 + +调用点若把**标量**(或任何非 `FilterCondition` 值)交给第三参,编译器会指出来: + +``` +error TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'. +``` + +改法是把它写成它本来就该是的裸 filter 对象(`'completed'` → `{ status: 'completed' }`)。⚠️ 这类调用点在收窄前拿到的是**未过滤的全集**,所以这不是一次等价改写:修完之后返回值会变,而变化后的那个才是调用方本来想要的答案。本仓零处这样的调用点。 diff --git a/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts new file mode 100644 index 0000000000..3f29e01017 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#6320 — `SqlDriver.distinct`'s third argument is a bare + * `FilterCondition`, never `any`. + * + * `distinct` is not declared on `IDataDriver`, so #5181's narrowing and + * #6075's follow-through never reached it; it kept `filters?: any` while its + * body said something much more specific — `applyFilters(builder, filters)` + * takes the argument ITSELF, so what it wants is the value `find()` carries + * under `query.where`, not a query envelope. This file holds the type to that + * sentence, and — just as important — records the two places the type provably + * CANNOT help, so the next reader does not assume a guard that is not there. + * + * **Why the compile-time pins here are real.** They are resolved by `tsc`, not + * by vitest: reverting the signature to `any` makes each `@ts-expect-error` + * directive unused, and an unused directive is itself an error, so + * `pnpm --filter @objectstack/driver-sql typecheck` goes red. That works here + * only because this package's `tsconfig.json` does NOT exclude the test glob + * and the package carries no DEBT / TEST_DEBT entry in + * `scripts/check-type-check-coverage.mjs` — it reports zero errors, which is + * the baseline these pins move away from. Every pin sits on a REAL CALL to + * `distinct`, never on a `const x: FilterCondition = …` literal: an + * alias-scoped pin stays green through a revert of the signature, which is the + * dead-pin shape #5018/#4984 paid for. + * + * **Measured before writing any of this** (three rows: `Laptop`/`Mouse` are + * `completed`, `Ghost` is `pending`), on `distinct('orders', 'product', …)`: + * + * ``` + * bare filter { status: 'completed' } => RESOLVED ["Laptop","Mouse"] + * no filter => RESOLVED ["Laptop","Mouse","Ghost"] + * envelope { object, where } => THREW INVALID_FILTER / 400 + * envelope { where } only => THREW INVALID_FILTER / 400 + * array ['status','=','completed'] => THREW INVALID_FILTER / 400 (#5158) + * scalar 'completed' => RESOLVED ["Laptop","Mouse","Ghost"] ← silent widening + * ``` + * + * The last line is what the narrowing removes: a truthy non-object `where` + * emits no predicate at all (see the closing comment of `applyFilters`), so a + * call meaning "distinct products among completed orders" type-checked and + * answered with EVERY product. That is #6320's "the direction is widening", on + * this driver, and it is now a compile error. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { SqlDriver } from './index.js'; + +/** `true` for `any` and for nothing else — `0 extends 1 & T` holds only there. */ +type IsAny = 0 extends 1 & T ? true : false; + +describe('SqlDriver.distinct takes a bare FilterCondition (#6320)', () => { + let driver: SqlDriver; + let knexInstance: any; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + knexInstance = (driver as any).knex; + + await knexInstance.schema.createTable('orders', (t: any) => { + t.string('id').primary(); + t.string('product'); + t.string('status'); + }); + await knexInstance('orders').insert([ + { id: '1', product: 'Laptop', status: 'completed' }, + { id: '2', product: 'Mouse', status: 'completed' }, + { id: '3', product: 'Ghost', status: 'pending' }, + ]); + }); + + afterEach(async () => { + await knexInstance.destroy(); + }); + + describe('the signature', () => { + it('no longer erases the filter to `any`', () => { + // Read off the METHOD, not off the `FilterCondition` alias. A revert that + // puts `any` back on the signature while leaving the alias imported would + // sail past any alias-scoped assertion; here the slot resolves to `never` + // and this line goes red. + type DistinctFilters = Parameters[2]; + const narrowed: IsAny extends true ? never : 'narrowed' = 'narrowed'; + expect(narrowed).toBe('narrowed'); + }); + }); + + describe('what still compiles, unchanged', () => { + it('takes the bare filter its body has always read', async () => { + // No cast, no envelope. This is the spelling `applyFilters` consumes and + // the one both driver-sql and driver-sqlite-wasm already used. + const products = await driver.distinct('orders', 'product', { status: 'completed' }); + expect(products.sort()).toEqual(['Laptop', 'Mouse']); + }); + + it('still means "no constraint" when the filter is omitted', async () => { + const products = await driver.distinct('orders', 'product'); + expect(products.sort()).toEqual(['Ghost', 'Laptop', 'Mouse']); + }); + }); + + describe('what the narrowing now rejects', () => { + it('refuses a truthy scalar — the spelling that silently returned everything', async () => { + await driver.distinct( + 'orders', + 'product', + // @ts-expect-error - a bare scalar is not a FilterCondition (#6320) + 'completed', + ); + }); + + it('refuses a number in the same slot', async () => { + await driver.distinct( + 'orders', + 'product', + // @ts-expect-error - a bare scalar is not a FilterCondition (#6320) + 42, + ); + }); + + it('measures WHY: forced through a cast, that shape still answers unfiltered', async () => { + // `as unknown as FilterCondition` — the #6204 spelling for deliberately + // off-contract input: it names the contract being bypassed instead of + // erasing the whole call with `as any`. + // + // `applyFilters` emits NO predicate for a truthy non-object, non-array + // filter and says so in its closing comment; widening that refusal is a + // separate change with its own blast radius (#5158 scoped it out), so + // what #6320 buys on this driver is the compile-time door above. This + // assertion is the reason that door is worth having — delete the door and + // this answer is what a plain call gets. + const products = await driver.distinct( + 'orders', + 'product', + 'completed' as unknown as FilterCondition, + ); + expect(products.sort()).toEqual(['Ghost', 'Laptop', 'Mouse']); + }); + }); + + describe('what NO type here can reject — pinned at runtime instead', () => { + it('admits a query envelope at compile time, then refuses it loudly at runtime', async () => { + // ⚠️ Deliberately NOT a `@ts-expect-error`. `FilterCondition` is an open + // map (`[key: string]: any`) because a filter key IS a field name, so + // `{ object, where }` is a structurally valid filter — one constraining + // columns literally named `object` and `where`. No annotation can + // separate that from a legitimate filter, so #6320's "pin the reverse + // mismatch as a compile error" is not achievable on this parameter, by + // any type. Writing `@ts-expect-error` here would go red TODAY. + // + // The guarantee that IS available is that this driver never answers such + // a call silently, which is the half of #6320's asymmetry driver-sql + // owns: the envelope's `where` value is an object, and no comparand may + // be one. + const envelope: FilterCondition = { object: 'orders', where: { status: 'completed' } }; + await expect(driver.distinct('orders', 'product', envelope)).rejects.toMatchObject({ + code: 'INVALID_FILTER', + status: 400, + }); + }); + + it('admits an array at compile time, then refuses it as a FilterArray (#5158)', async () => { + // Same reason: an array satisfies a string index signature, so the + // FilterArray authoring form reaches `distinct` type-checked. It is + // refused by the shared `applyFilters` door — `distinct` inherits that + // refusal rather than carrying its own. + const asArray = ['status', '=', 'completed'] as unknown as FilterCondition; + await expect(driver.distinct('orders', 'product', asArray)).rejects.toMatchObject({ + code: 'INVALID_FILTER', + status: 400, + }); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 21e6043636..ccea470c48 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7,7 +7,7 @@ * Supports PostgreSQL, MySQL, SQLite, and other SQL databases. */ -import type { DriverOptions, SchemaMode } from '@objectstack/spec/data'; +import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; // The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's // "the protocol has no such function" refusal cannot drift from what @@ -3685,7 +3685,36 @@ export class SqlDriver implements IDataDriver { // Distinct // =================================== - async distinct(object: string, field: string, filters?: any, options?: DriverOptions): Promise { + /** + * Distinct values of one field, optionally constrained. + * + * The third argument is a **bare {@link FilterCondition}** — the same value + * `find()` carries under `query.where`, NOT a query envelope. The body has + * always said so (`applyFilters(builder, filters)` is handed the argument + * itself, never a `.where` off it); `filters?: any` simply left that sentence + * out of the type, and #6320 measured what the omission costs. + * + * What the annotation actually buys, measured rather than assumed (#6320): + * + * - **A truthy SCALAR no longer compiles.** `distinct('orders', 'product', + * 'completed')` used to type-check and RESOLVE the *unfiltered* set — + * `applyFilters` emits no predicate for a non-object, non-array `where` + * (see the closing comment there). That silent widening is the family + * #6320/#5234 are about, and it is what this narrowing removes. + * - **A query envelope still compiles, and that is not fixable here.** + * `FilterCondition` is an open map (`[key: string]: any`) because a filter + * key is a *field name*, so `{ object, where }` is structurally a perfectly + * good filter — one that constrains columns named `object` and `where`. + * No type can separate it from a legitimate filter. It is caught at + * RUNTIME instead, loudly: `INVALID_FILTER` / 400 out of + * {@link assertCompilableComparand}, because the envelope's `where` value + * is an object and no comparand may be. `driver-memory`'s half of that + * asymmetry (a bare filter there returns the unfiltered set in silence) + * stays open under the #5499 freeze; this driver's half never was silent. + * + * Held by `sql-driver-distinct-filter-narrowing.test.ts`. + */ + async distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); if (filters) { diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts new file mode 100644 index 0000000000..8077bcbbf7 --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#6320, the wasm side — the narrowed `distinct` signature survives + * the `dist/*.d.ts` boundary. + * + * `SqliteWasmDriver extends SqlDriver` and `distinct` is not overridden here, + * so nothing in this package re-implements the narrowing. What this file pins + * is that the narrowing actually ARRIVES: this package does not compile + * driver-sql's `src`, it reads the `.d.ts` tsup emits, so a signature that is + * correct in the source but not re-built is invisible from here. That is a + * known gate blind spot — `turbo`'s `typecheck` task depends on `^build`, so CI + * rebuilds driver-sql first, but a local run in a stale worktree does not, and + * "driver-sql is green so the consumer is green" is exactly the assumption + * #4405 exists to disprove. + * + * Verified by deletion during implementation: with an impossible member + * temporarily added to the parameter type in driver-sql's SOURCE and the + * package rebuilt, every third-argument call site in THIS package went red — + * proof that the `.d.ts` being read is the freshly built one and not a stale + * copy. See the PR for the counts. + * + * The `@ts-expect-error` pins are resolved by `tsc` + * (`pnpm --filter @objectstack/driver-sqlite-wasm typecheck`), not by vitest; + * this package excludes no test glob and carries no DEBT / TEST_DEBT entry in + * `scripts/check-type-check-coverage.mjs`, so its measured baseline is zero + * errors. Both pins sit on real calls. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqliteWasmDriver } from './index.js'; + +/** `true` for `any` and for nothing else. */ +type IsAny = 0 extends 1 & T ? true : false; + +describe("SqliteWasmDriver inherits distinct's bare-FilterCondition parameter (#6320)", () => { + let driver: SqliteWasmDriver; + let knexInstance: any; + + beforeEach(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + knexInstance = (driver as any).knex; + + await knexInstance.schema.createTable('orders', (t: any) => { + t.string('id').primary(); + t.string('product'); + t.string('status'); + }); + await knexInstance('orders').insert([ + { id: '1', product: 'Laptop', status: 'completed' }, + { id: '2', product: 'Mouse', status: 'completed' }, + { id: '3', product: 'Ghost', status: 'pending' }, + ]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('reads a narrowed filter slot off the inherited method', () => { + type DistinctFilters = Parameters[2]; + const narrowed: IsAny extends true ? never : 'narrowed' = 'narrowed'; + expect(narrowed).toBe('narrowed'); + }); + + it('still takes the bare filter both drivers already used', async () => { + const products = await driver.distinct('orders', 'product', { status: 'completed' }); + expect(products.sort()).toEqual(['Laptop', 'Mouse']); + }); + + it('refuses the truthy scalar that silently returned the unfiltered set', async () => { + await driver.distinct( + 'orders', + 'product', + // @ts-expect-error - a bare scalar is not a FilterCondition (#6320) + 'completed', + ); + }); +}); From edb1249105677abe1b72a5e001c66b7066db876e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:09:49 +0000 Subject: [PATCH 2/4] spec(migrations): register the distinct narrowing in the ADR-0087 ledger (#6320) The #6148 gate asks a declared-breaking changeset what its ledger disposition is. Answered as `registered` rather than exempted, and the three mechanical categories are all genuinely closed to this changeset: - `unpublished` -- @objectstack/driver-sql and @objectstack/driver-sqlite-wasm are both published (private: false), so it fails outright. - `already-registered` -- no existing id covers this surface. - `no-migration-prescription` -- the changeset body carries a worked FROM -> TO migration section, so the gate refuses this category by construction. Using it would be a dodge, not an answer. The entry says up front that it records a TYPE being added rather than a surface being withdrawn, because that distinction decides who has to do anything. It renders into the upgrade guide's "Semantic (delegated to you)" section, carries no `migrate meta` implication and names no stored field -- the same disposition four existing code-surface entries already carry: data-driver-find-stream-retired (#4484), storage-service-list-retired (#5540), actor-user-roles-to-positions (#6011), driver-aggregate-undeclared-key-aliases-removed (#6321). The reason an entry is owed at all: the narrowing is compile-time only, so an untyped JS caller gets neither an error nor a behaviour change -- and that caller is exactly the one sitting on the silent-widening defect, before and after. The generated upgrade guide is the only channel that reaches them. Also moves both runtime pins in the driver-sql suite onto inline argument positions. A `const envelope: FilterCondition = ...` proves only that the alias admits the shape; the claim being pinned is that it reaches THIS PARAMETER uncast, which only an argument position can show (#5018 / #4984 dead-pin shape). Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-Authored-By: Claude Opus 5 --- .../sql-distinct-bare-filter-condition.md | 4 ++ ...l-driver-distinct-filter-narrowing.test.ts | 26 +++++++--- packages/spec/src/migrations/registry.ts | 52 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/.changeset/sql-distinct-bare-filter-condition.md b/.changeset/sql-distinct-bare-filter-condition.md index 0e6f1087fb..36643264f9 100644 --- a/.changeset/sql-distinct-bare-filter-condition.md +++ b/.changeset/sql-distinct-bare-filter-condition.md @@ -41,3 +41,7 @@ error TS2345: Argument of type 'string' is not assignable to parameter of type ' ``` 改法是把它写成它本来就该是的裸 filter 对象(`'completed'` → `{ status: 'completed' }`)。⚠️ 这类调用点在收窄前拿到的是**未过滤的全集**,所以这不是一次等价改写:修完之后返回值会变,而变化后的那个才是调用方本来想要的答案。本仓零处这样的调用点。 + +⚠️ 无类型的 JS 调用方**既不会拿到编译错误、也不会有任何行为变化**(本次零运行时改动)。对他们而言,上面那条是「你一直没在过滤」的**唯一通知渠道** —— 这也是本次记台账条目的理由,见下。 + + diff --git a/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts index 3f29e01017..476da9ef74 100644 --- a/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts @@ -157,20 +157,30 @@ describe('SqlDriver.distinct takes a bare FilterCondition (#6320)', () => { // a call silently, which is the half of #6320's asymmetry driver-sql // owns: the envelope's `where` value is an object, and no comparand may // be one. - const envelope: FilterCondition = { object: 'orders', where: { status: 'completed' } }; - await expect(driver.distinct('orders', 'product', envelope)).rejects.toMatchObject({ + // + // Written INLINE at the call site on purpose. A `const envelope: + // FilterCondition = …` would prove only that the alias admits the shape; + // the claim being pinned is that it reaches THIS PARAMETER uncast, which + // only an argument position can show. + await expect( + driver.distinct('orders', 'product', { object: 'orders', where: { status: 'completed' } }), + ).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400, }); }); it('admits an array at compile time, then refuses it as a FilterArray (#5158)', async () => { - // Same reason: an array satisfies a string index signature, so the - // FilterArray authoring form reaches `distinct` type-checked. It is - // refused by the shared `applyFilters` door — `distinct` inherits that - // refusal rather than carrying its own. - const asArray = ['status', '=', 'completed'] as unknown as FilterCondition; - await expect(driver.distinct('orders', 'product', asArray)).rejects.toMatchObject({ + // Same reason, and MEASURED rather than assumed: an array satisfies the + // string index signature, so the FilterArray authoring form reaches + // `distinct` type-checked. No cast here, deliberately — a cast would make + // this case survive a `FilterCondition` that had stopped admitting arrays + // while the sentence above quietly became false. It is refused by the + // shared `applyFilters` door, which `distinct` inherits rather than + // carrying its own copy of. + await expect( + driver.distinct('orders', 'product', ['status', '=', 'completed']), + ).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400, }); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b4cad0e607..1c4db0a418 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2304,6 +2304,58 @@ const step17: MigrationStep = { + 'undeclared function, identically on the local driver and the Turso remote ' + 'transport.', }, + { + id: 'driver-sql-distinct-bare-filter-typed', + // No backticks in `surface` — see the note on the entry above. + surface: 'SqlDriver.distinct() third argument — any value', + replacement: + 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries ' + + 'under query.where, never a query envelope', + reason: + 'This entry records a TYPE being added, not a surface being withdrawn, and it says ' + + 'so up front because the distinction decides who has to do anything. `distinct` is ' + + 'not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept ' + + '`filters?: any` while its body said something far more specific — ' + + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` ' + + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one ' + + 'statement moved, so no upgrade breaks at run time and nothing that answered ' + + 'correctly stops. What the annotation removes is a compile-time hole, measured ' + + 'rather than assumed: a truthy NON-OBJECT third argument — ' + + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve ' + + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy ' + + 'non-object, non-array filter. A call meaning "which products among completed ' + + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the ' + + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there ' + + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the ' + + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` ' + + '(#5540), `actor-user-roles-to-positions` (#6011) and ' + + '`driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It ' + + 'differs from those four in ONE measured way a reader should not have to infer: ' + + 'because nothing changed at run time, an untyped JS caller is not affected BY THE ' + + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly ' + + 'the one tsc can never reach, and the silent widening above is a defect they may ' + + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is ' + + 'the only channel that reaches them, which is why the fix is written down rather ' + + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can ' + + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter ' + + 'key IS a field name, so a query envelope `{ object, where }` is structurally a ' + + 'valid filter — one constraining columns named `object` and `where` — and so is a ' + + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' + + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' + + 'BARE spelling returns the unfiltered set in silence — stays open under the #5499 ' + + 'freeze (#6320). ADR-0087, #6320.', + acceptanceCriteria: + 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' + + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' + + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was ' + + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That ' + + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the ' + + 'answer changes once fixed, and the changed answer is the one the call always meant. ' + + 'An untyped JS caller gets no compile error and no behaviour change — for them this ' + + 'entry is the only notice that the spelling never filtered anything. A query ' + + 'envelope or a FilterArray in that slot still compiles and is rejected at run time ' + + 'with INVALID_FILTER / 400.', + }, ], }; From 75954e94d7f3006057e051d5f8b1d17b06b46c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:19:55 +0000 Subject: [PATCH 3/4] spec(migrations): regenerate the ADR-0087 projections for the new ledger entry (#6320) `spec-changes.json` and `docs/protocol-upgrade-guide.md` are a pure projection of the registries, and `check:spec-changes` / `check:upgrade-guide` pin the synchrony. The ledger entry landed without them, so both gates were red: spec-changes.json is stale -- the ADR-0087 registries changed without regenerating the manifest. Regenerated, not hand-edited (`pnpm --filter @objectstack/spec gen:spec-changes` and `gen:upgrade-guide`). The entry renders where the disposition claims it does and nowhere else -- under "Semantic (delegated to you, with acceptance criteria)" at line 372, below the section head at 274, and NOT into the "Mechanical (applied for you)" table at 221-273. So it carries no `migrate meta` implication, which is the whole reason a code-surface entry is allowed to sit in this ledger at all. Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-Authored-By: Claude Opus 5 --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f1ea8e88f9..92e0d86b0b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -369,6 +369,9 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser - **`spec-type-alias-input-suffix-retired`** — `type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)` → the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes. - Why not automatic: This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279). - Done when: No source imports a name ending `Input` from `@objectstack/spec` except the nine listed above: `rg "\b\w+Input\b" --type ts` over consumer code resolves only to those. A literal annotated with a bare spec type compiles while listing ONLY the keys the author means — `const c: Connector = { name, label, type }` type-checks, which it did not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced `XInput` synonym. +- **`driver-sql-distinct-bare-filter-typed`** — `SqlDriver.distinct() third argument — any value` → a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope + - Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320. + - Done when: No caller passes a non-object to `distinct()`'s third argument. A scalar there is now a compile error (`TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'`); rewrite it as the bare filter it was always meant to be — `'completed'` becomes `{ status: 'completed' }`. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400. --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 159be5a8eb..cf720faca7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -651,6 +651,13 @@ "migrationId": "spec-type-alias-input-suffix-retired", "toMajor": 17, "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)." + }, + { + "surface": "SqlDriver.distinct() third argument — any value", + "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", + "migrationId": "driver-sql-distinct-bare-filter-typed", + "toMajor": 17, + "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." } ], "removed": [] @@ -1361,6 +1368,13 @@ "migrationId": "spec-type-alias-input-suffix-retired", "toMajor": 17, "rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)." + }, + { + "surface": "SqlDriver.distinct() third argument — any value", + "replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope", + "migrationId": "driver-sql-distinct-bare-filter-typed", + "toMajor": 17, + "rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320." } ], "removed": [] From 76b2838bbea9f14f77c9afbf6f622328dcd16db2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:03:50 +0000 Subject: [PATCH 4/4] test(drivers): the distinct pin fixtures stop reaching knex through `as any` (#6320) Both new suites opened with `(driver as any).knex`, the idiom ~50 sibling files in these two packages already use. It is not a query/options erasure -- the `check:query-options-erasure` ratchet's vocabulary never matched it, and its count is unchanged either way -- but a file whose entire subject is "this parameter stopped being `any`" should not introduce `any` into its own harness. `knex` is `protected` on `SqlDriver`, so a fixture outside the class does need a cast. The #6204 spelling names the one member being reached instead of erasing every member of the driver to get at it: knexInstance = (driver as unknown as { knex: Knex }).knex; `knexInstance` is typed `Knex` rather than `any` as a result, which types the `createTable` callback as `Knex.CreateTableBuilder` for free. Sibling suites are deliberately NOT swept -- out of scope for this card, and worth its own pass. Re-verified after the change, not assumed: driver-sql and driver-sqlite-wasm both typecheck clean, 967 + 257 tests pass, eslint clean on both files, and the erasure ratchet still reports 263 test-surface sites in 49 files. Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-Authored-By: Claude Opus 5 --- .../sql-driver-distinct-filter-narrowing.test.ts | 13 ++++++++++--- .../sqlite-wasm-distinct-filter-narrowing.test.ts | 9 ++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts index 476da9ef74..125a895e2f 100644 --- a/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-distinct-filter-narrowing.test.ts @@ -44,6 +44,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Knex } from 'knex'; import type { FilterCondition } from '@objectstack/spec/data'; import { SqlDriver } from './index.js'; @@ -52,7 +53,7 @@ type IsAny = 0 extends 1 & T ? true : false; describe('SqlDriver.distinct takes a bare FilterCondition (#6320)', () => { let driver: SqlDriver; - let knexInstance: any; + let knexInstance: Knex; beforeEach(async () => { driver = new SqlDriver({ @@ -60,9 +61,15 @@ describe('SqlDriver.distinct takes a bare FilterCondition (#6320)', () => { connection: { filename: ':memory:' }, useNullAsDefault: true, }); - knexInstance = (driver as any).knex; + // `knex` is `protected` on SqlDriver, so a fixture outside the class cannot + // read it without a cast. Sibling suites in this package spell that + // `(driver as any).knex`, which erases EVERY member of the driver to reach + // one; this names the single member being reached instead (#6204 spelling). + // A file whose whole subject is "this parameter stopped being `any`" should + // not introduce `any` in its own harness. + knexInstance = (driver as unknown as { knex: Knex }).knex; - await knexInstance.schema.createTable('orders', (t: any) => { + await knexInstance.schema.createTable('orders', (t: Knex.CreateTableBuilder) => { t.string('id').primary(); t.string('product'); t.string('status'); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts index 8077bcbbf7..d5275054d0 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-distinct-filter-narrowing.test.ts @@ -28,6 +28,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Knex } from 'knex'; import { SqliteWasmDriver } from './index.js'; /** `true` for `any` and for nothing else. */ @@ -35,13 +36,15 @@ type IsAny = 0 extends 1 & T ? true : false; describe("SqliteWasmDriver inherits distinct's bare-FilterCondition parameter (#6320)", () => { let driver: SqliteWasmDriver; - let knexInstance: any; + let knexInstance: Knex; beforeEach(async () => { driver = new SqliteWasmDriver({ filename: ':memory:' }); - knexInstance = (driver as any).knex; + // Inherited `protected knex`, reached by naming the one member rather than + // erasing the driver with `as any` — see the note in driver-sql's twin. + knexInstance = (driver as unknown as { knex: Knex }).knex; - await knexInstance.schema.createTable('orders', (t: any) => { + await knexInstance.schema.createTable('orders', (t: Knex.CreateTableBuilder) => { t.string('id').primary(); t.string('product'); t.string('status');