Skip to content

Commit 17ace71

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6536-retire-export-field-meta-constraints
2 parents cc7a9a6 + 6de592c commit 17ace71

122 files changed

Lines changed: 9430 additions & 576 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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): judge unique violations with the shared predicate, so a Postgres index build over dirty data no longer takes the boot down (#6543)
6+
7+
`syncDeclaredIndexes` has a branch whose whole job is to keep a database
8+
BOOTING when existing rows violate a NULL-safe unique it was asked to create
9+
(the #5030 defect made data): the constraint is logged at `error` as not
10+
enforced, and the ADR-0120 D4 drift pre-flight reports the exact conflicting
11+
rows. Taking the process down instead would brick the deployment.
12+
13+
It decided whether it was looking at that case with a private inline regex over
14+
the stringified message — `unique constraint failed|duplicate entry|duplicate
15+
key value`, the fourth hand-written spelling of this question #6250
16+
inventoried. That read one of the two channels drivers use, and on the DDL path
17+
the missing channel is the whole answer for one shipped dialect:
18+
19+
| dialect | `CREATE UNIQUE INDEX` over duplicate rows says | old regex |
20+
|:---|:---|:---|
21+
| SQLite | `UNIQUE constraint failed: product.code` | matched |
22+
| MySQL | `ER_DUP_ENTRY: Duplicate entry 'DUP' for key 'uniq_…'` | matched |
23+
| Postgres | `could not create unique index "uniq_…"`, SQLSTATE 23505 | **missed** |
24+
25+
Postgres does not reuse its DML phrasing for an index build: `duplicate key
26+
value violates unique constraint` is what a conflicting INSERT says, while a
27+
conflicting index BUILD says `could not create unique index "…"` and puts the
28+
verdict on `error.code` (SQLSTATE `23505`) with the offending tuple on
29+
`error.detail`. None of the three message limbs appear in it — so on Postgres
30+
the branch never fired, and a database with legacy duplicates failed to start
31+
rather than booting with the constraint reported as unenforced.
32+
33+
Both discriminators in this file now call `isUniqueViolationError` from
34+
`@objectstack/types`, passing the **error object** rather than a pre-stringified
35+
message, so `code`, `errno` and the `cause` chain are read alongside `message`:
36+
37+
- the #5030 boot-survival branch above;
38+
- the negative limb of the MySQL functional-key-part fallback in
39+
`createNullSafeUniqueIndex`, which used a bare `/duplicate/i` to avoid
40+
degrading a conflict into a "this server rejects functional key parts"
41+
verdict — a message-only exclusion that did not fire on the `errno`-only
42+
shape mysql2 can hand back.
43+
44+
`patch` rather than `minor`: no API changes, and the message spellings that
45+
were recognised before are a strict subset of what the predicate recognises, so
46+
nothing that was absorbed before is absorbed differently now. The site's own
47+
business logic — the `nullSafe.size > 0` guard that keeps this absorption
48+
scoped to the NULL-safe case, and the "already exists" race arm that runs ahead
49+
of it — is unchanged.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/formula": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
字段级 `*When` 的未绑定根检查:黑名单翻成白名单,并把因果句按槽位分档
7+
8+
同一段诊断上的两条**正交**分档轴,一次设计通过 —— 分开做会把这段文案写两遍,
9+
且第二遍推翻第一遍。
10+
11+
## 轴一:根集合从 3 项黑名单翻成 3 项白名单(#6713)
12+
13+
字段级 `visibleWhen` / `readonlyWhen` / `requiredWhen` 实测只绑 `record`
14+
`previous``parent` 三个根,三处独立证据一致:服务端
15+
`rule-validator.ts` 的两处绑定(`readonlyWhen`
16+
`{ record, previous, extra: { parent } }`,`requiredWhen`
17+
`{ record, previous, ...parentScope }`);客户端 `evalFieldPredicate`
18+
`record` + `previous` + 调用方 `scope`,而 objectui 全部五个字段级调用点
19+
(`form.tsx` ×3、`WizardForm.tsx``GridField.tsx`)传的 `scope` 只可能是
20+
`undefined``{ parent }`;作者端 objectui 的
21+
`FIELD_RULE_ROOTS = ['record', 'previous', 'parent']`,注释明写 "nothing else"。
22+
23+
而检查此前是一张**黑名单** —— #6584 一项、#6711 三项
24+
(`current_user` / `user` / `ctx`)。黑名单在这个面上结构性地追不上
25+
`SCOPE_ROOTS`:每新增一个根都要有人记得抄过来(`current_user` 自己就是 #6290
26+
加进去、#6584 才被发现的)。实测有 **21 个根**落在这条缝里,它们同样未绑定、
27+
同样 fault、而且同样**静默** —— 都在 `SCOPE_ROOTS` 里,所以裸引用检查也从不
28+
报它们。其中两个是高可信度的作者笔误而非理论成员:
29+
30+
- `os.user.id` —— ADR-0068 D1 的**第四种**用户拼写(`buildScope` 把同一个
31+
`EvalUser` 挂在 `current_user` / `user` / `ctx.user` / `os.user` 下),#6711
32+
收了三种,`os` 这一支没收;
33+
- `data.status == 'x'` —— `data`**元数据表单**里同一个 `visibleWhen` 键的
34+
**合法**根(`view.zod.ts`:"Root: `record` … in runtime forms, or `data` in
35+
metadata forms"),两种表单同一个键名、不同的根。
36+
37+
判定改为 `SCOPE_ROOTS` 成员减去白名单,列表直接从 `@objectstack/formula` 取,
38+
不在消费端重述 —— 因此 `SCOPE_ROOTS` 将来新增的成员自动被覆盖。
39+
40+
处方随之**按根分档**:用户根(`current_user` / `user` / `ctx` / `os`)保留原有
41+
的选项级 `visibleWhen` 与权限集 FLS 两条用户向处方;`data` 给出元数据表单 vs
42+
运行期表单的解释;其余根给出通用的「改写成 `record` 谓词」。此前只有用户向处方,
43+
对写了 `data.type == 'select'` 的作者是答非所问。
44+
45+
## 轴二:因果句按槽位分档(#6716)
46+
47+
三个槽位此前共用一句「falls back to VISIBLE … showing for everyone」,而这句话
48+
只对其中一个精确。三格全部**实测**,每格量了两端:
49+
50+
- **`visibleWhen` —— 仅客户端、fail-OPEN,原文案正确。** 服务端根本不评估字段级
51+
`visibleWhen`(`ConditionalFieldDef` 无此成员,`fieldsNeedPrior` 只看
52+
`requiredWhen || readonlyWhen ||` 选项可见性),唯一裁决来自渲染端,
53+
`resolveFieldRuleState` 对可见性传 `fallback: true`
54+
- **`readonlyWhen` —— 两端方向相反,服务端说了算,原文案是反的。** 服务端
55+
`isReadonlyWhenLocked` 命中 `unknownVariableOf` 后返回 `true`(#4889
56+
carve-out,其触发条件正是未绑定根这一类),`stripReadonlyWhenFields` 随即把该
57+
字段从 payload 中删除;客户端 `resolveFieldRuleState``fallback: false`,
58+
表单仍渲染为可编辑。按 ADR-0057 D10(server enforces, client is courtesy)以
59+
服务端为准:作者改了字段、保存报成功、值静默不落库。原文案告诉作者「对所有人
60+
可见」—— 失败方向与排障方向都相反。
61+
- **`requiredWhen` —— 两端都 fail-OPEN,且与可见性无关。** 服务端记日志后
62+
`continue`(#4977 明确没有采用 #4889 的 carve-out),客户端 `fallback: false`,
63+
两端都不强制,记录带着空字段保存成功。原文案在这里不只是不精确,而是说错了
64+
字段的哪个属性。
65+
66+
`conditionalRequired``FieldSchema` 里是 `retiredKey`(按名字拒绝),解析后的
67+
编译路径上该分支是惰性的,因此给它一条与槽位无关的通用句,而不是编造第四格测量。
68+
69+
## `@objectstack/formula`
70+
71+
`SCOPE_ROOTS` 改为公开导出。一个绑定**封闭**根集合的面,必须能说出它****绑定
72+
的那些根,而那个补集就是 `SCOPE_ROOTS` 减去该面自己的白名单;消费端手抄的列表
73+
追不上这张表。注意它不能用 `firstUndeclaredReference` 替代:严格环境同时声明了
74+
CEL 的**类型名**,`type(record.x) == string` 里的 `string` 会被判成「能解析的根」
75+
—— 实测按可解析性判定会误杀这条合法谓词(1 例),按 `SCOPE_ROOTS` 成员判定不会。
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `lifecycle.storage` 的 rotation 文案补上方言限定 —— O(1) 整分片 DROP 仅在 SQLite 成立 (#6631)
6+
7+
`lifecycle.storage` 块的三处文案把 SQLite 独有的机制写成了 rotation 策略的无条件属性:
8+
`maxAge` guidance 的 "Rotation does not reap by age — it ... DROPs the oldest shard
9+
whole"、`strategy` describe 的 "(O(1) reclaim)"、以及模块 TSDoc 的 "Rotator
10+
(time-shard + DROP oldest)"。实测权威:物理分片是 SQLite-only 的驱动能力
11+
(`driver-sql``supportsRotation` 只在 `isSqlite` 下为 true,`rotateShards`
12+
在其他方言直接拒绝),Postgres/MySQL 走 LifecycleService 的 `'rotation-fallback'`
13+
分支 —— 按 `created_at` 的年龄批量 reap,恰是旧文案宣称 rotation 不使用的机制。
14+
15+
三处文案改为驱动注释早已写对的表述:保留窗口(`shards` × `unit`)在所有方言上
16+
一致 —— 声明的边界处处成立;回收机制不一致 —— SQLite 整分片 DROP(O(1) 回收),
17+
其他方言按年龄 reap 同一窗口。`maxAge` guidance 的路由建议(用 `shards`/`unit`
18+
设窗口,或改用 `retention`)原样保留。
19+
20+
**纯文案修改,接受面零变化**:`LifecycleSchema` 接受/拒绝的输入集合与改动前
21+
逐字节相同;`superRefine`(含 `retention.onlyWhen` × rotation 的拒绝及其理由)
22+
未触碰。批 20 测试新增两条 pin:guidance 与 describe 必须同时点名两条腿
23+
(SQLite 的分片 DROP 与其他方言的按龄 reap),并各带反空洞守卫,防止整段文案
24+
消失时 pin 静默变绿。
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/driver-memory': patch
4+
'@objectstack/driver-mongodb': patch
5+
'@objectstack/driver-turso': patch
6+
'@objectstack/spec': minor
7+
---
8+
9+
drivers: `limit: 0` returns no records, on every driver and every read door
10+
11+
`limit: 0` was ruled in #6485 to mean **return no records**. Three of the five shipped
12+
drivers did not honour it, in three different ways — and the ones that disagreed
13+
returned **more** data than was requested, which on an ADR-0021 RLS read scope is
14+
over-reach rather than a loose filter. Reachable since #6578: the client now puts
15+
`top=0` on the wire, so the answer depended on which driver a deployment configured.
16+
17+
**`driver-memory` — the slice was dropped.** `find()` sliced with `if (query.limit)`,
18+
truthiness, and `0` is falsy. Measured before the fix, three rows seeded:
19+
`{ limit: 0 }` returned **3 of 3**, and `{ limit: 0, offset: 1 }` returned 2 — the
20+
OFFSET applied and the LIMIT silently did not, which is why every paging suite stayed
21+
green over it. Two more sites of the same shape in `memory-analytics.ts` (the `$limit`
22+
pipeline stage and the SQL string builder) moved with it. Mingo honours `{ $limit: 0 }`
23+
as zero records (measured), so presence is sufficient there.
24+
25+
**`driver-mongodb` — the value was forwarded faithfully, to a client that means
26+
something else by it.** `buildFindOptions` already tested presence, so `0` arrived
27+
exactly as written — but the MongoDB Node driver DEFINES `limit: 0` as *no limit*, so
28+
the answer was still the whole collection. Fixed with an explicit short-circuit that
29+
returns the empty result **before the client is consulted** (`[]` from `find`, `null`
30+
from `findOne`, which had the same hole). No round trip is made for a query whose
31+
answer is already known, and no future change in the upstream driver's reading of `0`
32+
can move this behaviour. Deliberately `=== 0`, not `<= 0`.
33+
34+
**`driver-sql` — two doors disagreed with a third.** `findRows()`, the door `find()`
35+
goes through, has always compiled `limit` on presence. Two others compiled it on
36+
truthiness:
37+
38+
- `findWithWindowFunctions()` — the live window-function read door (#4286). Returns
39+
rows, so this was user-visible wrong data: `{ limit: 0 }` returned the whole table.
40+
- `analyzeQuery()` / `explain()` — returns a plan. It compiled `select * from "orders"`
41+
where `find()` sent `... order by "id" asc limit ?`, so it explained a statement
42+
other than the one that would run.
43+
44+
`offset` moved with `limit` at both doors for internal consistency only. That half is
45+
**measured to change nothing**: knex elides a zero offset on better-sqlite3, Postgres
46+
and MySQL alike. It is pinned as the no-op it is rather than reported as a fix.
47+
48+
**`driver-turso` remote transport — an `OFFSET` with no `LIMIT` was a syntax error.**
49+
Surfaced by the new conformance control that reads with a bare offset. SQLite's grammar
50+
is `LIMIT expr [OFFSET expr]`, and this compiler emitted the two clauses independently,
51+
so `find(obj, { offset: N })` with no `limit` produced `near "OFFSET": syntax error`
52+
for **every** `N`, and only on the remote transport (the local half goes through knex,
53+
which synthesises the `LIMIT -1` no-limit sentinel). Remote now builds the same
54+
statement knex does.
55+
56+
Result sets only ever get **narrower**. A caller who wants every row should omit
57+
`limit` rather than pass `0`.
58+
59+
`@objectstack/spec` gains `PAGINATION_ZERO_LIMIT_CASES`, the shared conformance
60+
case-set pinning this — with controls, so "return nothing, always" cannot pass it. All
61+
**five** drivers answer it, with **no DEBT rows**: future drift goes red at
62+
`check:driver-conformance` rather than being discovered in production.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(metadata-protocol): a `/meta` object read serves the effective runtime schema, whichever layer answered (#6562)
8+
9+
`GET /api/v1/meta/object/:name` answered a **different set of fields** depending
10+
on which link of its resolution chain produced the answer, for the same object:
11+
12+
- **registry-backed** → the schema AFTER `applySystemFields`, so it carried the
13+
injected system columns — `created_at`, `created_by`, `updated_at`,
14+
`updated_by`, `organization_id`, `owner_id`, `owning_business_unit_id` — even
15+
when the author declared none of them;
16+
- **overlay-backed** (a `sys_metadata` customization row, or a MetadataService
17+
body) → the stored document VERBATIM, so every one of those columns was simply
18+
absent.
19+
20+
Whether an object carries an overlay is invisible to the caller, so the same
21+
request reported the platform's own columns or not, and nothing in the response
22+
said which had happened. `/meta` is the machine-readable contract clients and AI
23+
authors code against: an author reading an overlay-backed object saw no
24+
`created_at` / `owner_id` / `organization_id` and reasonably concluded the
25+
columns do not exist — while every one of them is real in the database,
26+
filterable, orderable, and enforced read-only on write.
27+
28+
**Every `/meta` object read exit now serves the effective schema.** The
29+
single-item read, the list, the cached/ETag branch, both draft reads and the
30+
layered read's `effective` layer all report the injected columns, with the same
31+
`readonly` / `system` markers the engine enforces (`owner_id` stays
32+
`readonly: false` — ownership is transferable). This is the presence half of the
33+
seam #4513 closed the value half of.
34+
35+
Three things deliberately did **not** change:
36+
37+
- **`?layers=1`'s `overlay` layer stays byte-verbatim.** Injection happens at the
38+
read exits only, so Studio's "what you customised" diff never shows a column
39+
nobody wrote. Only `effective` is injected.
40+
- **A `GET``PUT` round-trip still persists a byte-identical body** (#4326).
41+
The write path gained the strip counterpart: a field byte-identical to the
42+
platform's own definition is removed again on save, so a served document handed
43+
straight back stores exactly what it stored before — same checksum, same
44+
history diff. A declared `owner_id` carrying the author's own label is *not*
45+
the platform's definition and survives untouched.
46+
- **A declared system column stays the author's.** Injection only ever adds a
47+
column nobody declared; it never rewrites one that was.
48+
49+
Which columns an object carries is `resolveInjectedSystemColumns`
50+
(`@objectstack/spec/data`, #5378) — the same derivation `applySystemFields`
51+
consumes — so every opt-out (`systemFields: false`, `managedBy: 'better-auth'`,
52+
`systemFields.audit`/`.tenant`, `tenancy.enabled: false`, the per-tier
53+
`ownership` table, the `sys_*` namespace) is answered in one place and re-derived
54+
in none. **What** each column looks like moves to `@objectstack/metadata-core`
55+
(`AUDIT_FIELD_DEFS` and the three tenancy/ownership anchors, re-exported from
56+
`@objectstack/objectql` so the symbols still resolve there) — the same relocation,
57+
for the same dependency cycle, as the audit-governance table in #4513:
58+
`@objectstack/objectql` depends on `@objectstack/metadata-protocol`, so the read
59+
path could not import the definitions from the registry that provisions them.
60+
One table now feeds the injection pass and the read exits, so they cannot drift.
61+
62+
One key is deliberately not carried onto a served document: `organization_id`'s
63+
`indexed`. It is not a `FieldSchema` key — removed in the 16.x line (#2377,
64+
ADR-0049) and rejected by name by the strict schema — and its only consumer is
65+
`driver-mongodb`'s schema builder, which reads the registered schema and never a
66+
served document. It stays at the injection site; that the registry-backed read
67+
answers `_diagnostics: { valid: false }` because of it is filed as #6810.

.changeset/olive-donkeys-tickle.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Add the startup open-vocabulary verdict rule — "not registered YET" and "no provider at all" are the same value, and a verdict recorded from it is never retracted (#4776).
6+
7+
A boot fills its registries incrementally, so asking one "is X there?" while it is still filling is fine — the answer is simply not final yet. Turning that not-yet into a **verdict and recording the verdict** is the defect: the provider registers a moment later and nothing goes back to undo the record. One showcase cold start produced three instances of the shape in three unrelated subsystems (#4769, #4771, #4772).
8+
9+
`findStartupRegistryVerdicts(source, { file })` is a pure decision procedure over plugin source (parsed, never executed, never type-checked). It reports two rule ids:
10+
11+
- `startup-open-vocabulary-verdict` — inside `constructor` / `init` / `start`, a read of a capability vocabulary ADR-0018 keeps runtime-extensible whose conclusion is **recorded** (announced in a `warn`/`error` log, cached in an instance field or module binding, or persisted). All three parts, or it is not a finding — a read-only probe is legal and is not flagged.
12+
- `startup-verdict-assertive-wording` — emitted only at a site the first rule already flagged, when the diagnostic asserts a terminal outcome about a world that has not finished forming ("will fail at execution time", "you need Redis").
13+
14+
Every finding's hint prescribes the three shapes the fixes took: resolve where the value is used (a `kernel:ready` hook or a lazy accessor — `createLazyCacheRateLimitStorage()`, #4772), seal the vocabulary then judge (`AutomationEngine.sealNodeTypeVocabulary()`, #4771), or order the verdict after the mutation it describes (#4769). All three cures are recognised by shape and pass.
15+
16+
Severity is always `warning` — the rule reasons about a boot sequence it cannot execute, so it advises and never gates. The kernel SERVICE-registry half of the same family stays with `pnpm check:startup-registry-verdict`; the rule module states the measured division of labour between the two.

0 commit comments

Comments
 (0)