Skip to content

Commit 635c0e9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6808-registry-unregister-object
2 parents b6520dd + 578bdef commit 635c0e9

32 files changed

Lines changed: 2368 additions & 224 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/driver-turso": patch
4+
"@objectstack/spec": patch
5+
---
6+
7+
feat(drivers): lower `count_distinct` on the SQL family (#6409)
8+
9+
`count_distinct` has been declared by `AggregationFunction` since the enum was
10+
written, and until now no SQL backend compiled it: both faces of the SQL family
11+
refused it with `NOT_IMPLEMENTED` / 501. A dashboard measure asking for a
12+
deduplicated count against a SQL datasource got a capability-gap refusal for a
13+
query that was already correct.
14+
15+
This is the ENFORCE half of #6188's split ruling (maintainer, 2026-08-07).
16+
`array_agg` and `string_agg` took ADR-0049's remove leg and left the enum in
17+
protocol 17 — no SQL backend compiled them and `string_agg` had no single shape
18+
to lower to. `count_distinct` was deliberately kept on the other side of that
19+
split, on the strength of having exactly one portable lowering. That lowering
20+
now exists:
21+
22+
- **`driver-sql`**`SqlDriver.aggregate` emits `count(distinct "column")`, on
23+
every dialect the driver targets.
24+
- **`driver-turso`**`RemoteTransport.aggregate` emits the same, on the remote
25+
path. Both faces in one change, deliberately: `TursoDriver` picks between them
26+
from `url`, so a lowering that landed on one alone would mean one query
27+
answering two ways depending on a connection string.
28+
29+
**Semantics: distinct NON-NULL values of the target column** — the standard
30+
`COUNT(DISTINCT col)` answer, and the same one `objectql`'s in-memory fallback
31+
and `service-analytics`'s SQL strategy already give.
32+
33+
**`field` is now required for `count_distinct`.** `AggregationNodeSchema` makes
34+
`field` optional because `COUNT(*)` is a real spelling, but `COUNT(DISTINCT *)`
35+
is a syntax error in every dialect. A `count_distinct` aggregation with no
36+
`field` is refused up front with `INVALID_QUERY` / 400 and a message naming the
37+
fix, rather than being sent to the database and coming back as an opaque 500.
38+
Plain `count` with no `field` still means `COUNT(*)`, unchanged.
39+
40+
**The refusal message no longer names `count_distinct` as unsupported.** Both
41+
faces build their "Compiled here:" list from their lowering table, so the
42+
message now lists it among the functions that work. With this entry the declared
43+
aggregate vocabulary and the SQL family's compiled vocabulary are the same set.
44+
45+
**New shared conformance table.** `AGGREGATION_CASES` / `AGGREGATION_ROWS`
46+
(`@objectstack/spec/data`) is the standard both SQL faces are now run against —
47+
values over one fixture carrying duplicates and nulls, so a lowering that lost
48+
the dedup or counted NULL as a value fails on a number rather than passing a
49+
SQL-string assertion. `driver-memory` and `driver-mongodb` are inside the #5499
50+
freeze and are not enrolled; the table records what each would answer and why,
51+
rather than omitting them.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): 设置了 `api.apiPath` 时,9 条 direct-mount 路由跟随同一个 API base(#6306)
6+
7+
`RestServer.getApiBasePath()` 回答 `api.apiPath ?? `${basePath}/${version}``,
8+
`rest-api-plugin.ts` 为两个 direct-mount registrar(`packages.*` ×4、
9+
`datasources/:name/external/*` ×5)自行重算了一次 `${basePath}/${version}`,
10+
从不读取 `apiPath`。两个表达式只在 `apiPath` 未设时相等——于是设置了
11+
`apiPath` 的部署同时出现两个 API 前缀。实测(`apiPath: '/backend/api/v9'`,
12+
真实 `createRestApiPlugin(...).start()` 组合、记录型 host server 枚举全部
13+
挂载):**92 条路由中 83 条迁到 `{apiPath}`,恰好 9 条滞留 `/api/v1`**;
14+
`{apiPath}/openapi.json``isUnderBase` 过滤把这 9 条排除在文档之外
15+
(**71 paths**);`/discovery` 也如实通告了滞留位置
16+
(`routes.packages: '/api/v1/packages'`)——通告没有说谎,是挂载本身分裂了。
17+
18+
按 maintainer 裁定(Option 1,单一真相源):registrar 现在直接消费
19+
`restServer.getApiBasePath()` 的返回值——共享同一个值,而不是把 `??`
20+
表达式复制到第二处(复制正是这个缺陷的成因)。`getApiBasePath()` 因此
21+
`private` 变为 public,职责写入其 doc comment。
22+
23+
**行为变化,仅限设置了 `api.apiPath` 的部署**:这 9 条路由的 URL 从
24+
`/api/v1/...` 移到 `{apiPath}/...`,旧前缀不再服务(无兼容双挂载)。
25+
修复后实测 92 条全部挂在 `{apiPath}` 下,`{apiPath}/openapi.json`
26+
完整列出这 9 条(**71 → 79 paths**),`/discovery` 通告 `{apiPath}/packages`
27+
`{apiPath}/datasources`
28+
29+
需要动手的只有**基础设施配置**:若反向代理、健康检查或外部监控里硬编码了
30+
`/api/v1/packages``/api/v1/datasources/*/external/*`,改成 `{apiPath}/…`
31+
**SDK 与应用代码无需改动**:`@objectstack/client`#6633 / PR #6712 起从
32+
`/discovery` 通告的 base 派生这两个面,而通告是已录制挂载的投影,因此客户端
33+
按构造跟随本次移动。该键也没有 authoring 路径可达
34+
(`defineStack({server:{api:…}})` 被 strict 块 loud 拒绝,`api:{apiPath}`
35+
静默 strip,`os serve` 只转发两个 scoping 键),只有程序化组合
36+
`createRestApiPlugin` 的 embedder 能设到它。
37+
38+
**默认配置(未设 `apiPath`)逐字节不变**:两个表达式在该情形下同值;实测
39+
修复前后默认挂载表(92 条)、`{base}/openapi.json`(79 paths)与
40+
`/discovery` 通告完全一致,逐行 diff 无差异。
41+
42+
另修复同一来源的第二处分歧:插件旧表达式用 `||` 兜底(空串 `basePath`
43+
`/api`),`RestServer` 规范化用 `??`(空串保留)——`basePath: ''`
44+
route-manager 面挂 `/v1` 而 9 条挂 `/api/v1`,同样的分裂不需要 `apiPath`
45+
也会出现(实测 83/9)。读同一个值后该分歧不复存在。
46+
47+
Bump 判定为 `minor` 而非 `patch` / `major`。不是 `patch`:除了修缺陷,它
48+
改变了一个真实配置键下可观测的 URL 表面,并且新增了公共 API 面
49+
(`RestServer.getApiBasePath()``private` 转 public,是这次单一真相源的
50+
承载物)。不是 `major`:没有任何可授权(authorable)的键被移除或重命名,
51+
没有需要作者迁移的元数据(因而 ADR-0087 无可登记项),默认部署逐字节不变,
52+
受影响部署的客户端按构造跟随;唯一的 FROM → TO 落在部署方自己的代理配置上,
53+
而这些部署今天本就是 split-brain——本次是让 `apiPath` 被完整遵守,不是收回
54+
一个曾被兑现的承诺。
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/rest": major
3+
"@objectstack/spec": minor
4+
---
5+
6+
refactor(rest)!: 按 ADR-0049 退役 `ExportFieldMeta` 的八个约束键 —— 唯一的读者已随导入 dry run 的镜像一起退役 (#6536)
7+
8+
**BREAKING.** `@objectstack/rest` 导出的 `ExportFieldMeta` 不再声明
9+
`required` / `system` / `readonly` / `hasDefault` / `min` / `max` /
10+
`minLength` / `maxLength``buildFieldMetaMap` 也不再计算它们。
11+
`ExportFieldMeta` 本身、以及全部展示类键(`name` / `type` / `label` /
12+
`options` / `reference` / `displayField` / `multiple`)原样保留。
13+
14+
这是一次**休眠代码清扫,不是缺陷修复** —— 今天没有任何用户会撞上它。
15+
16+
## 为什么这八个键留不住
17+
18+
它们只为一个消费者存在:导入 dry run 手抄的前置校验镜像
19+
`firstMissingRequiredField` / `firstConstraintViolation`,framework#3956)。
20+
#4633 ruling D 已经退役了那份镜像(PR #6532)—— dry run 改为通过
21+
`DataProtocol.validateData` 向引擎要判决,而引擎读的是对象自己的 schema。
22+
于是 `buildFieldMetaMap` 每次导入照算不误、却**没有任何代码再读**,正是
23+
ADR-0049 enforce-or-remove 针对的「已声明、无人读」形状。PR #6532 当时重写了
24+
注释、把键留在原地,并写明退役是一次独立的清扫 —— 本 PR 就是它承诺的那次。
25+
26+
关键在于:这八个键**从来不是事实来源**`buildFieldMetaMap(schema)` 是从调用方
27+
自己传进来的那个 `schema`**派生**出它们的,所以这张表只是把调用方手里已有的
28+
事实抄了第二份。约束词表旁边没有执行者,却和展示词表并排站着 —— 这恰恰是
29+
AI 生成的消费端最容易误当成契约的形状。
30+
31+
## 迁移:FROM → TO
32+
33+
只有一类代码受影响:直接调用 `buildFieldMetaMap`(或通过
34+
`prepareImportRequest` 拿到 `PreparedImport.metaMap`)并读取这八个键的外部消费者。
35+
仓内、以及 `objectui` 同级仓,逐键逐类型核查后**读者为零**
36+
37+
```ts
38+
// FROM
39+
const meta = buildFieldMetaMap(schema).get('amount');
40+
if (meta?.required && !meta.hasDefault) reject();
41+
if (meta?.max != null && value > meta.max) reject();
42+
43+
// TO —— 从你本来就持有的那个 schema 上读,也就是引擎读的同一份
44+
const field = schema.fields['amount'];
45+
if (field?.required && field.defaultValue == null) reject();
46+
if (field?.max != null && value > field.max) reject();
47+
```
48+
49+
一行版:**把读取点从派生副本移回 `schema.fields[name]`**
50+
51+
`hasDefault` 没有一对一的替代键 —— 它本身就是派生谓词
52+
`defaultValue != null`,镜像的是引擎 `applyFieldDefaults` 的判断
53+
`packages/objectql/src/engine.ts``if (f.defaultValue == null) continue;`)。
54+
那条事实仍然成立,只是它的权威出处一直在引擎里,不在这份副本里;所以请读
55+
`field.defaultValue` 并自己套用同一个 `!= null` 判断。
56+
57+
⚠️ **请对着一次真实运行验证,而不是只看 tsc 变绿**:这八个是**可选**键,挂在一个
58+
本身继续存在的接口上,所以 JS 消费者(或任何 `any` 类型的读取)升级后读到的是
59+
`undefined`,编译期一个字都不会说。TypeScript 消费者才会在读取处收到编译错误。
60+
61+
字段定义上的 `required` / `min` / `maxLength`**照旧完全可写、且照旧由引擎强制** ——
62+
本次没有任何可编写或已存储的元数据形状发生变化。
63+
64+
<!-- adr-0087: registered export-field-meta-constraints-retired -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `_views` keys for named `listViews`/`formViews` entries are the runtime's, single spelling (#6422)
6+
7+
`validateTranslationReferences` accepted two spellings for a named view entry —
8+
the map key and the entry's inner `name` — while the composer
9+
(`expandViewContainerWithDiagnostics`) constructs the runtime identity from the
10+
map key alone and ignores `name` entirely. Per the #5164 ruling (canonical =
11+
the runtime identity's bare key), the named branches now read their keys from
12+
the composer, exactly as the default `list` already does: an inner `name`
13+
diverging from its map key stops being a legal bundle key (the runtime never
14+
resolves it), and a collision-renamed entry (`formViews.default` beside a
15+
default `list``default_2`) becomes legal under the renamed key — the one
16+
spelling that actually resolves — instead of being reported as an orphan.
17+
18+
Measured over all 12 ratchet-covered configs in this repo: `os lint` verdicts
19+
are byte-identical before/after (`added: 0 / removed: 0`).

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh
392392
- **`action-descriptor-resume-authority-default-flip`**`automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning
393393
- Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.
394394
- Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is a declaration nothing enforces (#5703), so an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is warned about by NEITHER channel — check those by hand against the same rule.
395+
- **`export-field-meta-constraints-retired`**`@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift
396+
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).
397+
- Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself.
395398

396399
---
397400

0 commit comments

Comments
 (0)