Skip to content

Commit 48a03e3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6621-revert-registry-refresh
2 parents 1a91686 + 8825a06 commit 48a03e3

36 files changed

Lines changed: 2110 additions & 56 deletions
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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
Translate the Setup app's `nav_sso_providers` navigation entry in all four
6+
locales.
7+
8+
`@objectstack/plugin-auth` contributes an **SSO Providers** entry into Setup's
9+
Access Control group (`sys_sso_provider`, priority 250), but no locale bundle
10+
carried a label for it: measured on `origin/main` `ea1d9165d`, a grep for
11+
`nav_sso_providers` over `en` / `zh-CN` / `ja-JP` / `es-ES` returned **0 each**,
12+
against a control probe (`nav_positions`) that returned 1 each. A deployment
13+
with an external IdP wired therefore rendered `SSO Providers` in English inside
14+
an otherwise fully translated Setup menu.
15+
16+
| locale | label |
17+
| --- | --- |
18+
| `en` | SSO Providers |
19+
| `zh-CN` | SSO 提供方 |
20+
| `ja-JP` | SSO プロバイダー |
21+
| `es-ES` | Proveedores SSO |
22+
23+
Each one matches that locale's existing `sys_sso_provider.pluralLabel`, since
24+
the nav entry opens exactly that object's list view.
25+
26+
**Why no gate caught it.** `pnpm check:app-nav-i18n` (#5750) boots the real
27+
composition and asserts every *merged* Setup nav id carries a label in every
28+
locale — and `plugin-auth` spreads its `navigationContributions` in only when
29+
`authManager.isSsoWired()` is true. In the composition that gate boots, this
30+
entry is never contributed, never merged, and so never judged; the gate's header
31+
already declared that bound. This id is consequently the one Setup entry no
32+
boot-time check can reach, so it is pinned by hand instead, next to the dead-key
33+
tombstone (#6660) it is the converse of: one list holds ids whose label must be
34+
**gone**, the other ids whose label must **stay**. Making the gate itself
35+
union-aware was considered and deliberately left unbuilt — a separate
36+
maintainer-facing call, not a prerequisite for labelling the ids it cannot see.

.github/workflows/check-links.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ jobs:
3333
- name: Checkout repository
3434
uses: actions/checkout@v7
3535

36+
# ADR cross-link gate (#6592). `docs/adr/` is deliberately NOT added to the
37+
# lychee globs below -- see the ⛔ note there for why -- so the decision
38+
# registry gets its own resolver, in the same advisory lane.
39+
#
40+
# Deliberately `node` and not `pnpm`: the script has zero dependencies, so
41+
# this lane needs no setup-node / corepack / `pnpm install`, and the whole
42+
# advisory job stays a checkout plus two checks. It is also why an author
43+
# can run it locally in any container (`pnpm check:adr-links` when a
44+
# workspace install exists, `node scripts/check-adr-links.mjs` when it does
45+
# not) -- which lychee, installed by the action at CI time, cannot offer.
46+
- name: Check ADR cross-links
47+
run: node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs
48+
3649
- name: Check links with lychee
3750
uses: lycheeverse/lychee-action@v2
3851
with:
@@ -55,6 +68,18 @@ jobs:
5568
# suffix that a site route omits. Without this pair the gate cannot go
5669
# green at all: 1286 root-relative links fail as "Cannot resolve
5770
# root-relative link ... provide a root dir".
71+
#
72+
# ⛔ Do NOT add `docs/adr/**/*.md` here. It looks like the one-line fix
73+
# for #6592 and it is not: measured on the pinned lychee 0.24.2, that
74+
# glob reports 8 broken links today, every one a pre-existing ADR →
75+
# source-tree link whose target moved out of this repo. This job would
76+
# be red on every PR from the moment it merged, which is how an
77+
# advisory lane becomes a lane nobody reads (#6028 landed it
78+
# advisory-first specifically to earn a green streak). `docs/adr/` is
79+
# checked by the `Check ADR cross-links` step above instead, which can
80+
# freeze those 8 on a shrink-only baseline and fail on a NEW one --
81+
# something neither `exclude` nor `.lycheeignore` can express, because
82+
# neither ever tells you an entry stopped being needed.
5883
args: >-
5984
--offline
6085
--root-dir ${{ github.workspace }}/content

content/docs/kernel/runtime-services/data-service.mdx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: services.data
3-
description: CRUD runtime helper API for records (`get`, `find`, `create`, `update`, `delete`).
3+
description: CRUD runtime helper API for records (`query`, `get`, `find`, `create`, `update`, `delete`).
44
---
55

66
# `services.data`
@@ -32,13 +32,38 @@ the code that **holds** the binding calls it, with plain arguments and no `ctx`
3232
## Methods
3333

3434
```ts
35-
services.data.get<T = any>(object: string, id: string): Promise<GetDataResult<T>>
35+
services.data.query<T = any>(object: string, query: Partial<QueryAST>): Promise<PaginatedResult<T>>
3636
services.data.find<T = any>(object: string, options?: QueryOptions | QueryOptionsV2): Promise<PaginatedResult<T>>
37+
services.data.get<T = any>(object: string, id: string): Promise<GetDataResult<T>>
3738
services.data.create<T = any>(object: string, data: Partial<T>): Promise<CreateDataResult<T>>
3839
services.data.update<T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>>
3940
services.data.delete(object: string, id: string): Promise<DeleteDataResult>
4041
```
4142
43+
### Two list entries, one preference
44+
45+
`query` and `find` both answer a list read with the same
46+
`{ records, total?, hasMore? }` envelope, and the Canonical source declares which
47+
of the two to prefer — the block above lists them in its declaration order.
48+
`data.query` is *"Advanced Query using ObjectStack Query Protocol"*; `data.find`
49+
carries an `@deprecated` tag in the same file: *"Use `data.query()` with standard
50+
QueryAST parameters instead. This method uses legacy parameter names."* The tag
51+
ships in the package's published type declarations, so an editor strikes `find`
52+
through at every call site and points at `query`. As with the options vocabulary
53+
below, the posture is the SDK's own, not this page's — it is recorded product
54+
direction ([#986](https://github.com/objectstack-ai/objectstack/issues/986):
55+
deprecate the legacy-parameter query entries, promote `data.query(AST)`).
56+
57+
Deprecated means "prefer `query`", not "scheduled for removal in this version":
58+
`find` remains fully functional, keeps both of its option vocabularies (see
59+
[below](#find-options-canonical-and-legacy)), and the Canonical source still
60+
names `QueryOptionsV2` *"the recommended interface for `data.find()` queries"*
61+
for callers that stay on it. The capability line between the two entries is
62+
real, though. `find` rides GET query parameters, so it has no spelling for a
63+
`search` term or for nested `expand` detail — it refuses a nested expand with an
64+
error whose own text says to use `data.query()`. `query` POSTs the full
65+
`QueryAST` as a JSON body (`POST /data/:object/query`) and carries all of it.
66+
4267
## Canonical source
4368
4469
Every sibling page in this chapter names a contract interface
@@ -60,6 +85,12 @@ key for key. A managed runtime binds `services.data` to this same shape.
6085
- `object`: short object name (for example `task`, `account`)
6186
- `id`: record ID for single-record operations
6287
- `data`: partial payload for create/update
88+
- `query` (`query`): a `Partial<QueryAST>` — the spec's query protocol shape
89+
(`packages/spec/src/data/query.zod.ts`): `where` / `fields` / `orderBy` /
90+
`limit` / `offset`, plus the AST-only clauses (`search`, `expand` with nested
91+
detail, `aggregations`, `groupBy`, `having`). The AST's own `object` key is
92+
not needed here: the server takes the target from the `object` argument (the
93+
URL path) and overwrites anything the body says
6394
- `options` (`find`): filtering, sorting and pagination — two vocabularies, one
6495
behaviour; see the table below
6596
@@ -97,7 +128,8 @@ silently rather than refused, so migrate an options object as a whole.
97128
## Returns
98129
99130
- `get`: single record payload
100-
- `find`: list payload + pagination metadata
131+
- `query`/`find`: list payload + pagination metadata — the same
132+
`PaginatedResult` envelope for both
101133
- `create`/`update`: mutated record payload
102134
- `delete`: `{ object, id, success }` — the spec's `DeleteDataResponse`. The
103135
flag is `success`, not `deleted` (#5638)
@@ -133,7 +165,18 @@ export async function recentOrdersForContact(data: DataService, contactId: strin
133165
limit: 20,
134166
});
135167

136-
return { contact, orders };
168+
// `query` — the Canonical source's preferred list entry — POSTs the same
169+
// words as a Partial<QueryAST> body, and carries the clauses `find` has no
170+
// GET spelling for. Here: per-relation `expand` detail, which `find`
171+
// refuses with an error that itself points at `query`.
172+
const { records: withContact } = await data.query<{ id: string; amount: number }>('sales_order', {
173+
where: { contact_id: contact.id },
174+
orderBy: [{ field: 'created_at', order: 'desc' }],
175+
limit: 20,
176+
expand: { contact_id: { object: 'contact', fields: ['name'] } },
177+
});
178+
179+
return { contact, orders, withContact };
137180
}
138181
```
139182

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs",
4343
"check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs",
4444
"check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs",
45+
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
4546
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs",
4647
"check:org-identifier": "node scripts/check-org-identifier.mjs",
4748
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs",

0 commit comments

Comments
 (0)