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
53 changes: 53 additions & 0 deletions .changeset/aggregate-driver-query-and-alias-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/driver-sql": major
"@objectstack/driver-turso": major
---

refactor(drivers)!: `aggregate` 的 query 参数收窄到 `DriverQuery`,并退役 `aggregate` / `func` 两个未声明别名 (#6212 批 B、#6321)

#5181(PR #6076)收窄了 `IDataDriver` 声明的六个方法,#6075(PR #6210)让五个驱动的实现跟上,#6212 批 A+E 处理了 SQL 驱动自有的另两道门。本次是同一条线上的 `aggregate`:`driver-sql`、`driver-turso` 的转发层与 `RemoteTransport` 三处,全部从 `query: any` 收到 `DriverQuery`(`@objectstack/spec/contracts`)。

`any` 在 query 参数上不是「对象名没检查」,而是**检查全关**:`where` 的 filter 方言、`groupBy` 的节点联合、`aggregations` 的节点形状——而这三样恰恰是这几个方法体读的全部内容。

## 一、退役两个协议从未声明的别名(#6321,ADR-0049)

```ts
const aggregates = query.aggregations || query.aggregate; // driver-sql
const funcName = agg.function || agg.func;
const aggregations = query?.aggregations || query?.aggregate || []; // RemoteTransport
const func = String(agg.function || agg.func || '');
```

`QueryASTSchema` 声明的是 `aggregations`,`AggregationNodeSchema` 声明的是 `function`;`aggregate` / `func` 在 `packages/spec` 里**一个字都没有**。实测全仓唯一书写者是这两个驱动包自己的 fixture(`sql-driver-advanced` 7 处、`sql-driver-queryast` 1 处、`sqlite-wasm-driver-advanced` 7 处、`sqlite-wasm-driver-queryast` 1 处),非测试面零书写者——#4984 那一家:**fixture 拼着别名,宽容分支就永远绿着活下去,没有任何测试能在删掉它时转红**。fixture 已按已声明拼写重拼,写者归零,PD#12 与 ADR-0049 enforce-or-remove 于是把这两条 `||` 一并删掉。

顺带删掉的还有 `|| ''`:它只在**两个键都没写**时才生效,而那时这一面把名字回引成 `""`、本地面回引成 `"undefined"`,同一份越界输入两种措辞(#5240)。别名在时这条岔路够不着,删别名恰恰让它够得着,所以同一次关掉。

**迁移**:`aggregate:` → `aggregations:`,`func:` → `function:`。写旧拼写的内联字面量现在是编译错误(TS2353);越过 `tsc` 的 JS 调用方,`aggregate:` 会静默拿不到聚合列,`func:` 则拿到已有的具名 400(`INVALID_QUERY`,#5907)。本仓实测需要改动的非测试调用点为零。

## 二、一处真实行为改动:`RemoteTransport` 现在会编 `GroupByNode` 联合

`GroupByNodeSchema` 是 `z.union([z.string(), z.object({ field, dateGranularity?, alias? })])`,而这一层把它当 `string[]` 读。收窄后 `tsc` 直接把这条假设摆上台面(TS2322)。联合的两半状况完全不同,所以这不是一个 cast 能了事的:

- **无 granularity 的结构化条目**(`{ field: 'region' }`)是 spec 合法、且**今天就会下推到驱动**的形状:objectql 的 aggregate 派发对它一律判为「受支持」(`engine.ts` 里逐字写着 `plain {field} object is fine`),`objectql/src/secret-fields.test.ts:341` 就是这个形状的活体。本驱动的**本地面**把它编成普通的 `GROUP BY "region"`,远端面却把它插值成 `"[object Object]"`、死在标识符安全检查里——一条查询两种答案、由连接串决定,正是 #6203 那个形状,而且**是活体不是休眠**:能力位 `queryDateGranularity` 只管带 granularity 的那一半,管不到这一半。现在读 `.field`,两面收敛。
- **带 dateGranularity 的条目**远端确实编不出来,而这一点是**已声明**的:remote 模式发布 `queryDateGranularity: {}`,引擎据此全部落到内存分桶,因此不会下推。缺的是「绕过能力位、直连驱动」的那个调用方该得到什么答案——现在得到 ADR-0112 信封(`NOT_IMPLEMENTED` / 501),与聚合函数「协议已声明、本后端编不出」用的是同一类,而不是一句 SQL 注入告警。

`alias` **不读**,与本地面一致:`SqlDriver.aggregate` 也不读它,只在这一面读会是新的分叉而不是修复。

## 三、`SqlDriver` 那一面的同一条件也换上了信封

`SqlDriver.aggregate` 对「本方言编不出这个 granularity」原本抛裸 `Error`(`code`/`status` 皆 `undefined` ⇒ `mapDataError` 落默认分支,一个具名能力缺口以不透明 500 到达调用方)。只给远端面加信封就会造出 #5907 花一整个 issue 才关掉的那种分叉——`TursoDriver` 由 `url` 选面,同一条件不能有两种线上身份。两面首句逐字一致(`Date bucketing by '<g>' is not supported by this backend.`),尾句各报**本面**编得出的 granularity,由一条跨包 parity 用例比对两个**运行时**消息钉住。

**消息文本变更**(可能影响按文本匹配的下游断言):

```
- SqlDriver: dateGranularity 'week' not supported on dialect 'better-sqlite3'. Engine must fall back to in-memory bucketing.
+ Date bucketing by 'week' is not supported by this backend. Bucketed here: day, month, quarter, year (dialect 'better-sqlite3'). … (code=NOT_IMPLEMENTED, status=501)
```

## 定级依据

标 major 与 #5181 / #6075 / #6210 一致:**源码级破坏性**(调用点内联字面量、以及被删的两个别名键),加上第二、三节两处真实的运行期改动。`check:api-surface` 只记录导出的存在与否、不记录签名,所以这条说明是该变更唯一的下游载体。

`driver-sqlite-wasm` 未列入:它整个继承 `SqlDriver.aggregate`,自身源码零改动(改的只有它的 fixture 与一条断言)——与批 A+E 的处理一致。它读的是 driver-sql 的 `dist/*.d.ts`,因此验证时**必须先重建 driver-sql** 再 typecheck/test,否则是假绿。

<!-- adr-0087: registered driver-aggregate-undeclared-key-aliases-removed -->
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,9 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser
- **`storage-service-list-retired`** — `contracts.IStorageService.list` → no replacement — track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket
- Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266).
- Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541).
- **`driver-aggregate-undeclared-key-aliases-removed`** — `driver aggregate() call argument — query.aggregate and aggregations[].func` → query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared
- Why not automatic: `SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. "Never declared" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. 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: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404).
- Done when: No caller passes `aggregate:` to a driver's `aggregate()`, and no aggregation entry spells its function `func:`; both are written `aggregations:` / `function:`. An inline literal still using either old spelling no longer type-checks (TS2353 at the call site). An untyped JS caller that keeps writing `aggregate:` silently receives no aggregate column — the grouping still happens, the measure is simply absent — and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the undeclared function, identically on the local driver and the Turso remote transport.

---

Expand Down
22 changes: 11 additions & 11 deletions packages/drivers/driver-sql/src/sql-driver-advanced.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
it('should sum values', async () => {
const result = await driver.aggregate('orders', {
where: { status: 'completed' },
aggregate: [{ func: 'sum', field: 'amount', alias: 'total_amount' }],
aggregations: [{ function: 'sum', field: 'amount', alias: 'total_amount' }],
});

expect(result).toHaveLength(1);
Expand All @@ -51,7 +51,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {

it('should count records', async () => {
const result = await driver.aggregate('orders', {
aggregate: [{ func: 'count', field: '*', alias: 'total_orders' }],
aggregations: [{ function: 'count', field: '*', alias: 'total_orders' }],
});

expect(result).toHaveLength(1);
Expand All @@ -61,7 +61,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
it('should calculate average', async () => {
const result = await driver.aggregate('orders', {
where: { status: 'completed' },
aggregate: [{ func: 'avg', field: 'amount', alias: 'avg_amount' }],
aggregations: [{ function: 'avg', field: 'amount', alias: 'avg_amount' }],
});

expect(result).toHaveLength(1);
Expand All @@ -70,9 +70,9 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {

it('should find min and max values', async () => {
const result = await driver.aggregate('orders', {
aggregate: [
{ func: 'min', field: 'amount', alias: 'min_amount' },
{ func: 'max', field: 'amount', alias: 'max_amount' },
aggregations: [
{ function: 'min', field: 'amount', alias: 'min_amount' },
{ function: 'max', field: 'amount', alias: 'max_amount' },
],
});

Expand All @@ -84,9 +84,9 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
it('should group by with aggregates', async () => {
const result = await driver.aggregate('orders', {
groupBy: ['customer'],
aggregate: [
{ func: 'sum', field: 'amount', alias: 'total_spent' },
{ func: 'count', field: '*', alias: 'order_count' },
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total_spent' },
{ function: 'count', field: '*', alias: 'order_count' },
],
});

Expand All @@ -104,7 +104,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
it('should handle multiple group by fields', async () => {
const result = await driver.aggregate('orders', {
groupBy: ['customer', 'status'],
aggregate: [{ func: 'sum', field: 'quantity', alias: 'total_qty' }],
aggregations: [{ function: 'sum', field: 'quantity', alias: 'total_qty' }],
});

expect(result.length).toBeGreaterThan(0);
Expand All @@ -118,7 +118,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
const result = await driver.aggregate('orders', {
where: { status: { $ne: 'cancelled' } },
groupBy: ['product'],
aggregate: [{ func: 'sum', field: 'quantity', alias: 'total_quantity' }],
aggregations: [{ function: 'sum', field: 'quantity', alias: 'total_quantity' }],
});

const laptop = result.find((r: any) => r.product === 'Laptop');
Expand Down
Loading
Loading