Skip to content

Commit 83afe73

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4774-showcase-inert-wirings
2 parents 7ac3106 + f61c8cf commit 83afe73

55 files changed

Lines changed: 4966 additions & 218 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
docs(plugin-auth): the `databaseHooks` doc comment no longer claims better-auth's adapter bypasses the ObjectQL middleware chain (#4802). The option's JSDoc in `auth-manager.ts` (and its two sibling copies — the wiring comment beside `composeDatabaseHooks`, and `AuthPluginOptions.databaseHooks` in `auth-plugin.ts`) justified "use `databaseHooks`, not an ObjectQL middleware" with a mechanism claim that no longer holds: *better-auth's adapter goes through `dataEngine` directly, bypassing the `ql.registerMiddleware` chain*.
5+
6+
Re-verified hop by hop against `main`: `ObjectQLPlugin` registers **one** engine instance under both service names (`registerService('objectql', this.ql)` and `registerService('data', this.ql)`, and nothing else in the repo registers `data`); `AuthPlugin` passes exactly that instance to `createObjectQLAdapterFactory`; the adapter writes with a plain `dataEngine.insert(objectName, …)` — there is no bypass or skip-middleware option to pass; and `ObjectQL.insert()` wraps its body in `executeWithMiddleware()`, whose only filter is the object name. So `ql.registerMiddleware(fn, { object: 'sys_user' })` **does** fire for better-auth's writes, and so do the engine's `beforeInsert`/`afterInsert` hooks — the SCIM identity-source stamp in `auth-plugin.ts` is built on precisely that.
7+
8+
The **rule is unchanged** — user-lifecycle invariants still belong in `user.create.after`, not in a `sys_user` middleware — but the reason is now the one that is actually true: **ADR-0093 D2**, one owner for the invariant on the one seam every creation path already flows through (self-signup, admin create-user, import, SSO JIT). The narrower fact that survives is written down instead of the false one: adapter writes carry `context.isSystem: true` (`withSystemContext`, pinned by `objectql-adapter.test.ts`), so every *authorization* middleware — security, sharing, the ADR-0092 identity write guard — early-returns by design; a middleware that gates on `isSystem` sees nothing, one that does not, runs.
9+
10+
The stale sentence is **refuted in place rather than deleted**, because it had been copied into cloud's agent-facing docs and had already killed the middleware option in two rounds of design work there (cloud#1012, handed over as cloud#1022). A reader arriving from one of those copies needs to see the claim named and corrected; a silent deletion would leave them assuming the framework comment is the stale one.
11+
12+
Comments only — no runtime behaviour changes, nothing released.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
---
3+
4+
Tooling-only: three more `scripts/check-*.mjs` gates now fail, by name, when a declared scan root cannot be resolved (#4930). Releases nothing — no package changes.
5+
6+
Same shape as #4916/#4934, in three more scripts: a walker that opens with `try { readdirSync(dir) } catch { return out; }` (or `catch { return []; }`) cannot distinguish "this root is clean" from "this root was never opened", because both produce zero findings. Each script now resolves every declared root up front and fails with the root's name and reason ("does not exist" / "exists but is not a directory") before drawing any conclusion from the scan, and the swallows inside the walks are gone — an error mid-walk means the corpus was only partly read, which must not print as a clean pass either.
7+
8+
- `check-single-authz-resolver.mjs``SCAN_ROOTS` (`packages/`). The duplicate-resolver scan was genuinely vacuous on a dead root; the script only went red by luck, because both `DELEGATORS` happen to live under that same root, so it reported "Delegator missing" for the wrong cause. Move a delegator, or add a second scan root, and the vacuous scan goes fully silent.
9+
- `check-startup-registry-verdict.mjs` — the `packages/` scan root. The old `existsSync` guard accepted a *file* and then blamed the empty corpus; and one level in, a directory the walk could not stat shrank the corpus while `files.length` stayed non-zero, so the loud empty-corpus guard never fired.
10+
- `check-driver-conformance.mjs``DRIVERS_DIR`, `CASE_SETS_DIR`, and each driver's `src/`. DISCOVERED/CLASSIFIED did catch a dead root, but as a *consequence* naming the wrong cause: a renamed `packages/spec/src/data` printed five "CASE_SETS names X, which <file> no longer exports" errors, which reads as five deliberate deletions rather than one directory that moved.
11+
12+
No whitelist and no `optional: true` flag in any of the three, deliberately — every one of these roots is a git-tracked directory with tracked files, so no checkout that can run these gates at the repo root is legitimately missing one. An optional marker would be a supported way to silence the failure instead of fixing the rename: the empty `catch {}`, spelled politely.
13+
14+
Each proof is bidirectional and permanent rather than a one-off in the PR description: every script's `--self-test` now renames a root away mid-run and requires red naming that root (and not the survivors), replaces one with a file and requires the `not a directory` verdict, dangles a symlink inside the walk and requires the error rather than a smaller corpus, then restores everything and requires green again. `check-single-authz-resolver.mjs` had no self-test at all and gained one, with its two original invariants driven over a real temporary tree; `package.json` chains `--self-test &&` into `check:authz-resolver` to match the other ten `check:*` entries.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `scripts/check-doc-authoring.mjs` now fails, by name, when one of its declared `ROOTS` cannot be resolved (#4916). Releases nothing — no package changes.
5+
6+
The walk was `for (const r of ROOTS) { try { walk(r, files); } catch {} }`. Rename, move or delete any one root and its ENOENT was swallowed in place: the scan finished the *remaining* roots and printed `✓ doc authoring guard: N files clean`, exit 0. Measured on this tree — with `.claude/` renamed away, the old code reported **215 files clean, exit 0** where the honest answer is 219. From outside, "all three roots are clean" and "one root was never opened" are the same green line with a smaller N, and nobody reads N. That is the sixth instance this week of one shape: a check that runs, is green, and structurally cannot reach part of its subject (#4690 / #4804 / #4835 / #4868 / #4890 / #4851).
7+
8+
`assertRootsResolvable()` now runs before any walking and throws a `DeadRootError` naming every root that is missing, unreadable, or not a directory; `main()` turns that into a red gate that says which root died and tells the author to follow the rename in `ROOTS` rather than restore a tolerant skip. **No whitelist and no `optional: true` flag**, deliberately: `.claude`, `skills` and `content` are all git-tracked directories with tracked files, so no checkout that can run this gate at the repo root is legitimately missing one. An optional marker added "just in case" would be a supported way to silence the failure instead of fixing the rename — the empty `catch {}`, spelled politely. Should a root ever become legitimately absent, that is a decision to record with its condition and a test, not a check to relax. The inner `try` is gone too: an error *during* the walk also means the corpus was only partly read, which must not print as a clean scan.
9+
10+
The proof is bidirectional and permanent, not a one-off in the PR description. `--self-test` (#4913) already walked a real temporary tree with the real walker; it now also renames one root away mid-run and requires red naming that root and *not* the survivors, replaces another root with a file and requires the `not a directory` verdict, then restores both and requires green again. Observing green proves nothing about a gate whose failure mode is scanning less — so the self-test observes red first, every run. Note what this closes that #4913's self-test could not: the old self-test stayed green with the repo's real `.claude/` renamed away, because it asserts over its own temp tree.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): export the delete-dispatch contract so test doubles can be pinned to it (#4550)
6+
7+
A test double that is **looser** than the implementation it replaces converts a
8+
green suite into no suite at all — silently, and on exactly the paths a double
9+
was introduced for, which are the paths that were hard to test, which are
10+
usually where the contract is densest. #4434 is the worked example:
11+
`DELETE /api/v1/sharing/rules/:idOrName` answered 500 for every rule and both
12+
address forms it advertises, from the day it was written, while
13+
`deleteRule drops rule + all its grants` asserted success against it the whole
14+
time — against a fake engine whose `delete` accepted the one call shape
15+
`ObjectQL.delete` refuses.
16+
17+
`ObjectQL.delete`'s dispatch decision now lives in one exported place instead of
18+
being re-derived by every fake:
19+
20+
```ts
21+
import { assertEngineDeleteDispatch } from '@objectstack/objectql';
22+
23+
async delete(object: string, options?: any) {
24+
assertEngineDeleteDispatch(options); // refuses what a real server refuses
25+
26+
}
27+
```
28+
29+
New exports, all pure and side-effect free:
30+
31+
- `resolveEngineDeleteDispatch(options)``{ kind: 'by-id', id }` |
32+
`{ kind: 'multi' }` | `{ kind: 'reject', message }` — what the engine will do
33+
with this call, without doing it.
34+
- `assertEngineDeleteDispatch(options)` — throws exactly what the engine throws
35+
on `reject`, returns the dispatch otherwise. This is the line a fake engine's
36+
`delete` opens with.
37+
- `scalarDeleteId(options)` — the SCALAR `where.id` or `undefined`. The half a
38+
hand-written mirror drops: `where: { id: { $in: [...] } }` looks like an id
39+
and is a multi-row predicate, so the engine rejects it without `multi`.
40+
- `ENGINE_DELETE_REJECT_MESSAGE`, `ENGINE_DELETE_DISPATCH_CASES` — the message
41+
and the shared conformance case-set, the same role
42+
`packages/spec/src/data/*-conformance.ts` plays for drivers.
43+
44+
`ObjectQL.delete` itself reads `resolveEngineDeleteDispatch`, so a double that
45+
imports it cannot be looser than the engine, ever — that is the property, and
46+
it is the one a hand-mirrored `if` can only have until somebody edits one side.
47+
No runtime behaviour changes: the same three verdicts, over the same inputs,
48+
proved case-by-case against the real engine in
49+
`engine-delete-dispatch.test.ts`.
50+
51+
Repo-side, `pnpm check:engine-double-contract` (wired into `lint.yml`) finds all
52+
39 fake ObjectQL engines in the repo, holds new ones to this predicate, and
53+
keeps the 30 not yet converted in a measured, shrink-only baseline.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): null-guard 闸门覆盖 `requiredWhen`,其余各面按"绑定是否全量"逐一定案 (#4811)
6+
7+
#4763 的 null-guard 闸门只接了两面(对象校验规则、生命周期 hook `condition`),
8+
其余各面留作"待定"。本次把"待定"收敛成一条**可判定的判据**,并按它逐面定案 ——
9+
一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。
10+
11+
## 判据:记录绑定是否对已声明字段**全量**
12+
13+
这不是口味问题,也不是"这个谓词是不是 CEL"。实测 `@marcbachmann/cel-js`,两种绑定
14+
下的语义**恰好相反**:
15+
16+
| 谓词 | 全量绑定 `{a: null}` | 稀疏绑定 `{}` |
17+
|:--|:--|:--|
18+
| `has(record.a)` | `true` ← 陷阱 | `false` ← 真守卫 |
19+
| `record.a < record.b` | FAULT `no such overload` | FAULT `No such key: a` |
20+
| `record.a != null` | `false`**修法有效** | FAULT `No such key: a` |
21+
22+
即:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下 `has()` 恰恰是正确的
23+
守卫,而 `!= null` **自身就会 fault**。把闸门指向一个稀疏绑定的面,等于判红正确的元数据、
24+
并给出一个会把它改坏的"修法" —— 比不覆盖更糟。所以:**只有绑定全量的面才可以接入。**
25+
26+
## 纳入:字段 `requiredWhen`
27+
28+
议题没有列出这一面,而它恰恰是唯一满足判据的:`evaluateValidationRules` 用与对象校验
29+
规则**同一个** `materializeDeclaredFields` 合并记录来求值 `requiredWhen`
30+
31+
它也是几个已覆盖面里失败得最安静的一个:`requiredWhen` 谓词 fault 时是 **fail-open** ——
32+
`rule-validator.ts` 记一行 `failed to evaluate — skipped` 就跳过,字段于是**从未真正必填**,
33+
写入照常通过。校验规则至少自 #4761 起是 fail-closed 的拒绝。因此报错文案按面区分后果:
34+
"被跳过、字段从未必填"与"写入被 fail-closed 拒绝"是两个相反的故障,作者需要知道自己
35+
碰到的是哪一个。
36+
37+
## 排除,且各自留下可引用的理由
38+
39+
- **action `visible` / `disabled`**:谓词确实走真 CEL(裸串经 `ExpressionInputSchema`
40+
规范成 `{dialect:'cel'}` 信封,渲染器保留它),fault 也确实 fail-closed —— 陷阱在这一面
41+
是真的。但绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行),
42+
`objectui` 这条路径上不存在任何物化步骤。稀疏绑定下 `!= null` 是错的修法。要覆盖它,
43+
得先决定是否把该绑定做成全量 —— 那是平台契约改动,不是 lint 改动。
44+
- **flow / edge `condition`**:议题记的理由(扁平作用域下裸标识符可能是 flow 变量)对本
45+
模块**不成立** —— 它只解析 `record.<f>` / `previous.<f>`,从不解析裸标识符,而引擎无
46+
条件绑定这两个根。真正的阻碍还是全量性:`record-change-trigger.ts` 把记录播种为
47+
`{ ...inputDoc, ...after }`,没有 `materializeDeclaredFields`,所以写入未提及的已声明列
48+
**缺键**而非 null,`!= null` 会和它本要守卫的比较一样 fault。
49+
- **字段 `readonlyWhen`**:与 `requiredWhen` 同一个字段、相反的结论 —— 它由
50+
`stripReadonlyWhenFields` 求值,那里合并的是 `{ ...previous, ...data }`,从不物化。
51+
- **`Field.formula`**:按产品判断排除,而非按本判据。formula 是 `value` 角色、天然可空,
52+
`guard ? value : null` 是被祝福的写法(#3306)。是否强制守卫会改变"作者被允许写什么",
53+
该由维护者决定,不是一个接线缺口。
54+
55+
判据、实测表与逐面台账写在 `validate-null-guards.ts` 的模块注释里,每条排除在它对应的
56+
调用点也留了注释,并各配一条断言钉住。
57+
58+
## 顺带修正:`field '?'`
59+
60+
诊断的字段名此前走 `Object.values(fields)`,把**名字键**丢掉了 —— 而名字键正是
61+
`Field.text({…})` 这种(最常见的)写法产生的形状,于是这类对象上的每条字段级诊断都定位在
62+
`field '?'`。名字只出现在 `where` 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
ci(release): add the objectui pin-freshness gate (#3340 P0). `scripts/check-objectui-pin-fresh.mjs` fails when `.objectui-sha` is not objectui `main` (or a named `--ref`), naming the commits ahead and the `.changeset/*.md` files declared after the pin — the blind spot that dropped four frontend changes, two of them `minor` features, from the v16 release page. Wired as `Console Pin Freshness` in `.github/workflows/objectui-pin-freshness.yml`: it runs on every PR so the context can be required in branch protection, but blocks only on the Version Packages / release PR. Distinct from ci.yml's `Console Pin Gate` (#4290), which proves the pin still *builds* rather than that it is still *current*. Tooling and CI only; releases nothing.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/metadata-protocol": major
4+
---
5+
6+
feat(spec,metadata-protocol)!: a sort node spelling its direction `direction` is a 400, not a silently reversed page (#4721)
7+
8+
**FROM → TO:** `orderBy: [{ field: 'updated_at', direction: 'desc' }]`
9+
`orderBy: [{ field: 'updated_at', order: 'desc' }]`. One word. If you are on the
10+
`{field, direction}` shape because you moved code over from
11+
`IReportService.orderBy`, that contract is unchanged — it is `orderBy` on the
12+
QueryAST / `EngineQueryOptions` axis that has always been `{field, order}`.
13+
14+
## What was wrong
15+
16+
`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied.
17+
Measured on `main` before this change:
18+
19+
```
20+
SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' })
21+
→ { field: 'updated_at', order: 'asc' }
22+
```
23+
24+
`direction` was discarded and `order` fell back to its `asc` default. The sort
25+
therefore ran in the **opposite** direction and the request succeeded. Paired
26+
with `limit` — which is how a caller asks for "the latest N" — that is not a
27+
reordered page but a **different set of rows**, returned under an ordinary 200
28+
with nothing in the response to distinguish it from the answer that was asked
29+
for.
30+
31+
`direction` is not a typo. It is the live vocabulary of a neighbouring contract,
32+
`IReportService.orderBy` (`@objectstack/spec/contracts`), and
33+
`plugin-auth/objectql-adapter.ts` already translates between the two by hand — a
34+
translation known to be necessary and enforced nowhere, which is the ADR-0049
35+
shape.
36+
37+
## What changed
38+
39+
Both doors onto that shape, in one change:
40+
41+
1. **`SortNodeSchema`** (`spec/src/data/query.zod.ts`) is now `strictObject`
42+
with `aliases: { direction: 'order' }`. An unknown key is rejected, and
43+
`direction` specifically gets the translation in the error message — edit
44+
distance can never bridge `direction``order`, so a bare "unrecognized key"
45+
would leave the caller exactly where the silent strip did.
46+
2. **`normalizeSortNodes`** (`metadata-protocol/src/protocol.ts`), the ingress
47+
every REST/RPC `orderBy` funnels through, refuses `{ field, direction }` with
48+
`400 INVALID_SORT` naming `order` and quoting the corrected node. Closing only
49+
the schema would repeat the door asymmetry of #1535/#4522: `SortNodeSchema` is
50+
reachable by three paths the REST normalizer never sees.
51+
52+
| `orderBy` you send | Before | After |
53+
|:--|:--|:--|
54+
| `[{ field: 'x', order: 'desc' }]` | descending | unchanged — descending |
55+
| `[{ field: 'x', direction: 'desc' }]` | **200, ascending** | `400 INVALID_SORT`, message names `order` |
56+
| `[{ field: 'x', order: 'desc', direction: 'asc' }]` | 200, descending | `400 INVALID_SORT` |
57+
| `'-x'` / `['-x']` / `{ x: 'desc' }` | descending | unchanged |
58+
| `{ direction: 'desc' }` (the `{field: direction}` map) | sorts by column `direction` | unchanged — a column may legitimately be called `direction` |
59+
60+
Scope is deliberately narrow: **`QuerySchema`'s top level is untouched** and
61+
still accepts undeclared keys (`QuerySchema.safeParse({ object: 'sales',
62+
nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map
63+
for its own batch, not smuggled in here.
64+
65+
Related: #4674, #4720, #4363, #4371, #4001, ADR-0049.

0 commit comments

Comments
 (0)