Skip to content

Commit 47243c4

Browse files
committed
Merge origin/main into claude/issue-6486-adr0049-retire-sweep (2)
生成物按 os-regen 四步:先取 origin/main 版本落 merge commit,随后整体重生成。 手工消解两处冲突: - migrations/registry.ts —— 两侧各自在 step17.semantic[] 末尾追加,合并为四条 (main 的 driver-sql-distinct-bare-filter-typed + 本 sweep 的三条) - type-alias-convention.pin.test.ts —— 计数三段叙事合并: 751 →(#6037)754 →(#5933)755 →(本 sweep -7)748
2 parents 3213cb8 + 3510e4a commit 47243c4

84 files changed

Lines changed: 5032 additions & 356 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/objectql": minor
4+
"@objectstack/service-analytics": patch
5+
---
6+
7+
refactor(spec)!: retire `array_agg` / `string_agg` from `AggregationFunction``count_distinct` deliberately kept (#6188, ADR-0049)
8+
9+
`AggregationFunction` declared eight functions; the SQL family compiles five.
10+
`SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower
11+
`count`/`sum`/`avg`/`min`/`max` and route everything else to one refusal, so
12+
three of the eight were declared-but-unenforced against the backends this
13+
platform targets — and, worse, the *set* each backend implemented was different,
14+
so "which aggregations can I use" had no answer an author could read off the
15+
schema.
16+
17+
What makes these two sharper than an ordinary inert declaration is that another
18+
package had to carry a denylist for them. `service-analytics` subtracted
19+
`array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because
20+
without that subtraction they reached the Cube strategy's `default` and came
21+
back as `COUNT(*)`**a row count in place of the value the author asked for**,
22+
with no error and no log (objectui#2945).
23+
24+
**The three unlowered functions were SPLIT, not retired as a block** (maintainer
25+
ruling, 2026-08-07):
26+
27+
- **`count_distinct` STAYS** and takes ADR-0049's *enforce* leg. It is a
28+
dashboard staple with one portable lowering (`COUNT(DISTINCT x)`), and
29+
`service-analytics` lowers it already; the SQL-driver implementation follows
30+
on its own card. Its declaration leads its implementation here by decision,
31+
not by drift.
32+
- **`array_agg` / `string_agg` take the *remove* leg.** Display conveniences
33+
with no measured pull, and `string_agg` never had one shape to lower to at
34+
all: the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in
35+
MySQL and a differently named function in SQL Server.
36+
37+
FROM → TO, both authoring surfaces:
38+
39+
| Was | Now |
40+
|:--|:--|
41+
| `aggregations: [{ function: 'array_agg', field: 'tag', alias: 'tags' }]` | no replacement — read the rows with an ordinary `fields` query and shape them in the caller, or materialise the roll-up as a stored field |
42+
| `aggregations: [{ function: 'string_agg', field: 'name', alias: 'names' }]` | as above |
43+
| `measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }]` | delete the measure — `compileDataset` already refused it by name, so it never produced a number |
44+
45+
The retirement kit:
46+
47+
- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone:
48+
the enum's own error map carries the prescription, keyed on the received value
49+
so that only the two spellings which used to be legal are told they "were
50+
removed" (the `crypto.hash` / `HookBodyCapability` precedent, #4391). A
51+
mis-spelling still gets zod's list of the legal functions. For the same reason
52+
nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are
53+
byte-identical — no def and no authorable key changed.
54+
- **ADR-0087 D2 conversion + D3 chain step**
55+
(`dataset-measure-array-string-agg-removed`): `os migrate meta --from 16`
56+
drops any `dataset.measures[]` declaring a retired aggregate, plus any derived
57+
measure the drop strands, with a notice each. The measure is dropped rather
58+
than stripped down because one with neither `aggregate` nor `derived` fails
59+
the dataset's own refinement — a conversion whose output cannot parse is worse
60+
than none.
61+
- **D3 semantic entry** (`query-array-string-agg-retired`) for
62+
`QueryAST.aggregations[].function`: a request surface, never stored, so there
63+
is no source for the chain to rewrite and callers move their own queries.
64+
- The engine's in-memory fallback (`@objectstack/objectql`) drops its arms for
65+
both functions — a `switch` case on a value the enum no longer has does not
66+
type-check, and a dead arm is how a retired vocabulary returns by accident.
67+
- `service-analytics`' `UNSUPPORTED_AGGREGATES` is now **empty and kept**: it is
68+
half of an arithmetic the lockstep suite enforces (`SUPPORTED = spec
69+
vocabulary − this`), which is what stops the next aggregate added to the spec
70+
from silently reaching that `COUNT(*)` default.
71+
72+
**Behaviour that actually changes** — this is the rare narrowing that removes
73+
reachable behaviour, and it is worth stating plainly: on `driver-mongodb` and on
74+
the engine's in-memory fallback these two DID compute. A raw QueryAST
75+
aggregation against those backends returned an array or a joined string and will
76+
now be refused at parse. That unpredictability is precisely what the ruling
77+
ended — an aggregation that worked on one backend and failed on another is not a
78+
capability — and both of those backends are inside the #5499 freeze. Their code
79+
is untouched; it is simply no longer reachable through a spec-valid request. On
80+
the dataset path nothing changes: `compileDataset` refused both by name already.
81+
82+
<!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed -->
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): 定形 `multi: true` 批量写在 `before*` 阶段的按行 hook 契约(#6462)
6+
7+
#5574 的维护者裁决(2026-08-06,方案 B)把「批量写按行语义为平台契约」(#4800 /
8+
#4862,after 侧已由 #5038 交付)延伸到 `before` 型 hook,并指定 **contract-first
9+
拆分:spec 契约子单先行,engine 实现随后**。本次变更是该拆分的 spec 半边 —— 只落
10+
契约、pin 测试与 ADR 附录,**engine 一行未动**
11+
12+
**为什么需要这条契约。** 谓词写路径上 `ctx.previous` 在 before 阶段从未被绑定,于是
13+
每一个按守卫写法写出来的 hook —— `if (ctx.previous?.locked) throw` —— 在批量写上
14+
静默放行。hotcrm 实测:一次批量编辑绕过全部 15 个守卫 hook,把单行路径会拒绝的
15+
`readonly: true` 字段写成 `null`。失效方向是 fail-open,而让它静默的可选链正是 AI
16+
会写出的形状。
17+
18+
**新增契约面** `@objectstack/spec/data``bulk-write-hook-conformance.ts`:
19+
20+
- `BULK_WRITE_HOOK_DISPATCH_CONTRACT` —— 四个写事件的按行派发表(before/after ×
21+
update/delete),逐条声明 per-row 上下文携带哪些键、载荷作用域,以及 **`delivered`
22+
标记**:after 半边由 #5038 交付(`true`),before 半边为已裁未交(`false`,
23+
engine 半边 = #5574 engine 卡)。契约先行必须能被读出「尚未交付」,否则它就是一次
24+
declared ≠ enforced。
25+
- `MAX_BULK_PER_ROW_HOOK_ROWS` / `BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE` /
26+
`resolveBulkPerRowHookBudget()` —— `assertBulkPerRowHookBudget` 先例的契约级表述:
27+
**两个阶段共用一个上限**,超限在**首次按行派发之前**整单拒绝(什么也不写、一个
28+
handler 也不跑),永不降级成「整批一次派发」。
29+
30+
**载荷可改写语义(裁决必答项 1)的答案是:载荷仍然只有一份,作用域是整批。** 每个
31+
per-row 上下文拿到的是**同一个** payload,而不是逐行副本 —— 于是 N 份载荷不可能分叉,
32+
没有合并步骤,谓词写永远不会被拆成 N 次单行写(仍是一次 `updateMany`、一个受影响行数
33+
#4639)。逐行副本 + 「一致则合并、分叉则拒绝」这条更显然的路线被实测证据否掉:objectql
34+
自带的 `sys_stamp_audit_update` 注册在 `'*'` 上,且在**每行**的 stamp 内部读
35+
`new Date()`,跨毫秒的两行 `updated_at` 天然不同 —— 该规则会非确定性地拒绝正常批量写。
36+
37+
**行为不变。** 本次不改任何 Zod schema 的接受面,不新增可授权键,不动 engine:今天能
38+
通过校验的元数据,改动后逐字节仍然通过。`hook.zod.ts``input` 形状表继续描述引擎
39+
**当前**的构造(由 objectql 对真实派发钉住),只是补了一条指向新契约的前瞻说明 ——
40+
表与引擎不允许抢跑,这正是 #5273 的教训。
41+
42+
ADR-0058 新增 **Addendum II**,同时收纳裁决指定的 #5748 半边(`data.id`
43+
`where.id` 统一走标量测试,已由 PR #5919 交付),并把 Addendum I 中「`before*` hooks
44+
are NOT per row」一段标记为 **SUPERSEDED**(原文保留 —— 被推翻的决策本身是记录)。
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): share-link enforcement takes the full `ExecutionContext`; the narrow context is route-401 only (#6430, #6206 ruling A)
6+
7+
`IShareLinkService.createLink` / `revokeLink` / `listLinks` now declare their
8+
context parameter as the complete `ExecutionContext` envelope instead of the
9+
five-field `ShareLinkExecutionContext`. All three ADJUDICATE access — the
10+
[Finding-2] visibility re-read on create, the ADR-0111 D8 share-manager probe
11+
on revoke, the context-scoped listing — so each needs the whole
12+
`resolveAuthzContext` result, `accessible_org_ids` / `org_user_ids` /
13+
`systemPermissions` / `posture` / `tabPermissions` included.
14+
15+
The measured failure behind the ruling: the share-link route assembled exactly
16+
those five fields and handed the result straight to `engine.find` as the
17+
enforcement context. Under the `group` tenancy posture `accessible_org_ids` IS
18+
the Layer 0 wall (ADR-0105 D2) and an absent set denies, so link creation
19+
returned a blanket 403 on a posture that ships. Fail-closed, not a leak — but a
20+
trimmed envelope feeding enforcement is a bypass-shaped pattern, and ADR-0095
21+
D2 already rules that posture is resolved once and carried, never re-derived at
22+
the enforcement site. This was the third assembly site of that family (#5997,
23+
#6071), so the contract converges on the whole envelope rather than keeping a
24+
per-site subset.
25+
26+
`ShareLinkExecutionContext` is retained and unchanged in shape — it is the
27+
route's own "authenticated or 401?" vocabulary — with TSDoc that now states the
28+
boundary and why TypeScript cannot enforce it (structural subtyping accepts a
29+
narrow object wherever the wide type is expected, so the declared parameter
30+
type plus the caller's obligation are what hold the line).
31+
32+
Contract-only, no runtime behaviour change here: existing implementations keep
33+
compiling (method parameters are bivariant), and the `@objectstack/plugin-sharing`
34+
consumer that actually threads the envelope through is the follow-up half
35+
tracked on #6206.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-mongodb": patch
5+
"@objectstack/driver-memory": patch
6+
"@objectstack/lint": patch
7+
---
8+
9+
refactor(spec,drivers,lint): one implementation of the filter identity reduction (#5659)
10+
11+
`{ $and: [] }` matches every row, `{ $or: [] }` matches none, `{}` is a TRUE
12+
disjunct that absorbs its `$or`, `{ $not: {} }` is FALSE. That is a ruling
13+
(#5322/#5134) pinned for every backend by the four identity cases in
14+
`FILTER_LOGIC_CASES` — and it was implemented four times over: `reduceFilterNode`
15+
in `driver-sql`, the same function again in `driver-mongodb`, the
16+
`every`/`some`/truthiness algebra of `driver-memory`'s matcher, and nearly a
17+
fifth hand-written copy inside `@objectstack/lint`, which declined to write one
18+
and filed this issue instead.
19+
20+
**New in `@objectstack/spec` (`@objectstack/spec/data`): `reduceFilterVerdict`**,
21+
beside the case table that proves it. It answers `'true' | 'false' | 'clause'`
22+
for a filter node and never throws on its own; each backend's own refusals — the
23+
undeclared `$`-combinator and the `undefined` comparand in `driver-sql`, the
24+
query-level keys and the `$null` comparand in `driver-mongodb` — are passed in as
25+
`FilterVerdictHooks` and are invoked from exactly the positions they were invoked
26+
from before. `reduceFilterKeyVerdict` answers the same question for one key, which
27+
is what both SQL and MongoDB emitters consult while walking a node.
28+
29+
**No behaviour changes in the three drivers.** The move is mechanical: the shared
30+
algebra replaces each private copy, the refusals stay where they were, and the
31+
`FILTER_LOGIC_CASES` conformance suites are green on both sides of the change —
32+
including the SQL-inheriting `driver-sqlite-wasm` and `driver-turso`.
33+
34+
**`@objectstack/lint` gains two warnings it was structurally blind to.** The
35+
`multi: true` unbounded-bulk-write rule (#5482) asked "does this filter have zero
36+
keys", so a `delete_record` bounded by `filter: { $and: [] }` or
37+
`filter: { $or: [{}] }` — a whole-object write by the ruling every driver executes
38+
— passed silently. It now asks the reduction, and it warns about both while
39+
staying quiet on `{ $or: [] }` and `{ $not: {} }`, which match nothing. The
40+
message names the shape it saw (`a filter that REDUCES TO TRUE ({"$and":[]})`)
41+
rather than calling a non-empty filter "empty".
42+
43+
If you have a flow declaring a bulk write bounded by one of those two shapes, the
44+
lint will now tell you so — the write was already unbounded at run time; only the
45+
feedback is new.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): SettingsManifest specifiers can declare a standard `valueDomain` (#5933)
6+
7+
`SpecifierSchema`'s value constraints were `options` / `pattern` / `min` / `max` /
8+
`minLength` / `maxLength`, and none of them can express "the legal values here are
9+
whatever the published standard says". `pattern` constrains the *shape* of a string,
10+
so `^[A-Za-z]{2}$` admits `ZZ` and `Mars/Olympus` is a shape-valid time zone that does
11+
not exist; `options` is exhaustive (#5131), so completing it would mean checking a
12+
600-entry tzdb table into a manifest and re-checking it every tzdb release. The
13+
`localization` manifest hits this on three keys at once — `timezone`, `currency`,
14+
`default_country` — and `company.country` carries the same two-letter pattern with the
15+
same hole.
16+
17+
**New optional key: `specifier.valueDomain`**, a closed enum with three members:
18+
19+
- `iana_time_zone`
20+
- `iso_4217_currency`
21+
- `iso_3166_alpha2`
22+
23+
Declaring it moves the enforcement boundary: the standard's membership becomes what a
24+
write is judged against, and `options` degrades to a **UI convenience list** — a curated
25+
dropdown of values worth suggesting, no longer an exhaustive statement of what is legal.
26+
A value outside `options` but inside the domain is accepted.
27+
28+
**Nothing changes when it is absent.** `options` stays exhaustive and the save path keeps
29+
rejecting anything the table does not list, which is the right shape for tables the
30+
platform itself backs (`mail.provider`, `sms.provider`) where "legal" means "this
31+
deployment ships an adapter for it". `pattern` / `minLength` / `maxLength` still apply
32+
alongside a domain and still narrow — shape and membership are independent, and a value
33+
must satisfy both.
34+
35+
The **enforcement** is not in this release. `packages/spec` declares the domain and
36+
nothing more (Prime Directive #2); the write-path check lands in `service-settings`
37+
(#5712, blocked on this). What ships here so both halves agree is the *definition of
38+
membership* for each domain, pinned by tests rather than left to prose, because for two
39+
of the three the obvious oracle is the wrong one:
40+
41+
- `iana_time_zone` is the `Intl.DateTimeFormat` probe, not
42+
`Intl.supportedValuesOf('timeZone')` — measured on the Node 22 baseline, that list
43+
holds 418 CLDR canonical names and omits `UTC` (this platform's own declared default)
44+
and `Asia/Kolkata` (a value the shipped localization manifest curates), carrying the
45+
latter only under the legacy spelling `Asia/Calcutta`.
46+
- `iso_4217_currency` **is** `Intl.supportedValuesOf('currency')` — 162 entries,
47+
admitting `CHF` and all nine curated options while rejecting `XYZ`.
48+
- `iso_3166_alpha2` has no standard-library oracle at all:
49+
`Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct name for `ZZ`
50+
("Unknown Region", the exact value this domain exists to reject) and for `UK` (a CLDR
51+
alias that is not an ISO 3166-1 code), so the enforcing side must carry an explicit
52+
code list.
53+
54+
`bcp47_locale`, the fourth member the proposal listed, is deliberately **not** in the
55+
vocabulary. Its only candidate key is `localization.locale`, whose options are exactly
56+
the shipped message catalogs — a registry-backed table, so a domain there would loosen
57+
it and admit locales with no catalog. And BCP-47 has no membership registry to enforce
58+
against (`Intl.getCanonicalLocales('xx-YY')` succeeds), so the "domain" would only
59+
re-check syntax — the weakness `pattern` already has and this key exists to fix.

0 commit comments

Comments
 (0)