Skip to content
47 changes: 47 additions & 0 deletions .changeset/sql-distinct-bare-filter-condition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@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' }`)。⚠️ 这类调用点在收窄前拿到的是**未过滤的全集**,所以这不是一次等价改写:修完之后返回值会变,而变化后的那个才是调用方本来想要的答案。本仓零处这样的调用点。

⚠️ 无类型的 JS 调用方**既不会拿到编译错误、也不会有任何行为变化**(本次零运行时改动)。对他们而言,上面那条是「你一直没在过滤」的**唯一通知渠道** —— 这也是本次记台账条目的理由,见下。

<!-- adr-0087: registered driver-sql-distinct-bare-filter-typed -->
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Loading
Loading