Skip to content

Commit 7d5cd66

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4001-ui-batch13
2 parents a9edc41 + 7d0e7b5 commit 7d5cd66

57 files changed

Lines changed: 4438 additions & 140 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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): the boot banner's `Tenancy:` row now reports the resolved posture, not the superseded boolean (#4801)
6+
7+
`printServerReady` printed `Tenancy: multi-tenant | single-tenant` from a boolean
8+
`multiTenant` that `serve` filled with `resolveMultiOrgEnabled()` — i.e. from
9+
`OS_MULTI_ORG_ENABLED`. [ADR-0105 D1] replaced that knob with
10+
`OS_TENANCY_POSTURE`, keeping the boolean only as the fallback
11+
`resolveTenancyPosture()` consults when the posture is unset, and **the runtime
12+
wiring in `serve` already keys off the posture**. So the banner and the server it
13+
describes read two different sources for one fact, and they drifted exactly where
14+
it hurts: booting with `OS_TENANCY_POSTURE=isolated` and `OS_MULTI_ORG_ENABLED`
15+
unset printed
16+
17+
```
18+
Tenancy: single-tenant
19+
Plugins: 40 loaded
20+
…, Organizations, …
21+
```
22+
23+
— the banner claiming single-org one line above the plugin table that proves the
24+
organization wall is up (observed on a real boot in cloud#1020, where the lie was
25+
only caught by hand-comparing the plugin list).
26+
27+
This is not cosmetic. It is the "declared ≠ enforced" class (ADR-0049) landing on
28+
the **diagnostic** surface, which is the worst place for it: a banner that can be
29+
wrong costs every later investigation an extra lap proving whether it is.
30+
31+
**What changes for users.** The row now prints the posture verbatim — `Tenancy:
32+
single`, `Tenancy: group`, `Tenancy: isolated` — sourced from the same
33+
`resolveTenancyPosture()` call the runtime wiring uses. The old `multi-tenant` /
34+
`single-tenant` vocabulary is gone. That vocabulary was itself part of the defect:
35+
tenancy has been a three-valued spectrum since ADR-0105, and a boolean has no
36+
spelling for `group` at all, so a `group` deployment could only ever be
37+
misreported.
38+
39+
**The internal `multiTenant` option is removed, not deprecated.** With the posture
40+
authoritative, a retained boolean could only ever be a field the printer ignores —
41+
and a field that exists but cannot be believed is precisely how this bug was
42+
authored in the first place. `ServerReadyOptions.tenancyPosture` is typed as
43+
`TenancyPosture`, so re-wiring the banner to the legacy boolean now fails to
44+
compile (`resolveMultiOrgEnabled()` returns `boolean`) instead of producing a
45+
plausible-looking wrong line. The interface is package-internal — `format.ts` is
46+
not re-exported from `@objectstack/cli`'s entry point — so no consumer code needs
47+
a change.
48+
49+
Regression-pinned in `packages/cli/src/utils/format.tenancy.test.ts`, which asserts
50+
the printed token **is** `resolveTenancyPosture()`'s answer across the cases that
51+
made the old code wrong: posture set with the boolean unset, posture unset with the
52+
boolean true, both set and contradicting (either direction), the legacy `multi`
53+
spelling, and `group`.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `dashboard.widgets[].responsive` — the straggler of the #3896 inert-key sweep (#4876, ADR-0049)
6+
7+
`DashboardWidgetSchema.responsive` let an author declare per-breakpoint layout
8+
overrides on a dashboard widget — `breakpoint`, `hiddenOn`, `columns`, `order`
9+
and no renderer ever read them. The value parsed, validated, and then did
10+
nothing: `DashboardRenderer`, `DashboardEditor` and `plugin-designer` name
11+
`responsive` only in comments, and the one genuine per-breakpoint consumer in
12+
objectui (`useResponsiveConfig`) is fed by `page.components[].responsive`, never
13+
by a widget. Re-measured 2026-08-03 across both repos, plus zero authored
14+
instances anywhere in this repo's examples, apps and tests.
15+
16+
Four days earlier, #3896 retired the **literally same-named** `view.responsive`
17+
on exactly this evidence. This embed survived that sweep for a reason that is
18+
worth stating plainly, because it is not "we looked and it was live": the
19+
liveness ledger declares no `children` on `dashboard.widgets`, and the walk
20+
drills only one level through an explicit `children` — so **no widget-level key
21+
has ever been classified at all** (22 of them). The instrument had a hole, not
22+
the key a mandate. That gap is filed and fixed separately as **#4956**.
23+
24+
Leaving it would have shipped v17 with one word and two fates — `view.responsive`
25+
a `tsc` error, `dashboard.widgets[].responsive` silently accepted — which no
26+
author or authoring agent could be expected to explain, on a key that today
27+
accepts *any* content on both sides (objectui types it a documented `any`). That
28+
is precisely where AI-authored metadata errors hide and multiply.
29+
30+
FROM → TO:
31+
32+
| Removed | Replacement |
33+
| :--- | :--- |
34+
| `dashboard.widgets[].responsive` (key) | **none** — delete it; the grid reflows by `columns` + `gap` on the dashboard and the `layout` box on each widget |
35+
36+
**The shape is NOT removed — only this embed.** `ResponsiveConfigSchema` /
37+
`ResponsiveConfig` stay exported and stay live on `page.components[].responsive`,
38+
whose renderer genuinely reads them. Nothing that imports the shape breaks, and
39+
an author who needs breakpoint behaviour today has a real place to put it. This
40+
narrowness is deliberate: the maintainer's ruling covers the dashboard widget
41+
surface only.
42+
43+
The retirement kit:
44+
45+
- **Tombstone.** `retiredKey()` on the widget key. `DashboardWidgetSchema` *is*
46+
`.strict()`, so a plain delete would still be loud — but only as a generic
47+
"unrecognized key". The tombstone keeps the key declared so the rejection
48+
carries the **prescription**, and types the key `never` so authoring it fails
49+
`tsc` first. A pin asserts the message is the prescription and *not*
50+
`Unrecognized key`.
51+
- **ADR-0087 D2 conversion + D3 chain step**
52+
(`dashboard-widget-responsive-removed`, `retiredFromLoadPath`):
53+
`os migrate meta --from 16` deletes the key from author sources, and stored
54+
dashboards replay clean instead of meeting the tombstone at load. A lossless
55+
delete — the key never had an effect to lose. Kept as its own entry rather than
56+
folded into `dashboard-inert-keys-removed`, whose identity is the #3896 sweep:
57+
this removal rests on its own 2026-08-03 measurement and should say so in
58+
`spec-changes.json` and the upgrade guide.
59+
- **No liveness row is added**, matching `widgets[].performance` in the #3896
60+
sweep — a widget-level row would be an ORPHAN, not a classification, until
61+
#4956 lands the drill. The ledger `_note` records the removal and why the row
62+
is absent.
63+
- Baselines moved at KEY level only, as the shape's survival implies:
64+
`authorable-surface.json` gains `ui/DashboardWidget:responsive [RETIRED]`;
65+
`json-schema.manifest.json`, `api-surface.json` and
66+
`api-surface-signatures.json` are unchanged by construction — no def stopped
67+
being emitted and no export was removed.
68+
69+
No runtime behaviour changes — that impossibility is the reason for the removal.
70+
71+
**objectui shard:** the outcome is retirement, not the fallback clause, so
72+
objectui#3235's conditional pin-bump item is permanently de-listed; the `any`
73+
declaration on that repo's side can be cleaned on its own schedule.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix: 节点执行与热重载 shutdown 的超时守卫在 race 落定时被清除,不再留下孤儿定时器 (#4952)
7+
8+
#4813(PR #4874,内核 init/start)与 #4875(PR #4950,周期性健康检查)修掉的是同一种漏法:
9+
守卫 armed 之后就被扔掉 —— 被守护的一方赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout`
10+
也没 `unref()`,带着 ref 一直把事件循环钉满整个超时预算。本次清仓剩下的两处生产实例:
11+
12+
- **`AutomationEngine.executeWithTimeout()`**(`service-automation`)—— 三处里量级最大的一处:
13+
**每个声明了 `timeoutMs` 的流程节点各一根**,孤儿数随流程节点数 × 触发频率线性增长;一次性进程
14+
(`os` CLI 跑到 flow 的路径)干完活之后还会被最长的那根守卫按住到超时才退出。
15+
- **`HotReloadManager.reloadPlugin()`**(`core`)—— 插件 `destroy()` 的 shutdown 守卫,与 #4813
16+
修掉的两处一字不差:一次毫秒级完成的热重载,照样把循环钉满 `shutdownTimeout`
17+
18+
两处修法与 #4874 / #4950 同形,不新造变体:私有 helper +
19+
`try { return await Promise.race([...]) } finally { clearTimeout(guard) }``hot-reload.ts`
20+
helper 把入参放宽到 `T | PromiseLike<T>`(Plugin 契约允许同步 `destroy()`);`engine.ts` 的不放宽
21+
(`NodeExecutor.execute` 声明返回 `Promise`)。
22+
23+
**为什么是 `clearTimeout` 而不是 `unref()`** `unref()` 让定时器不再钉住事件循环的同时,也让它
24+
不再是一个守卫 —— 若被守护的一方永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发之前
25+
退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是
26+
`finally { clearTimeout(guard) }` 表达的语义。两处的回归测试各自沿用 #4950 的双向写法:
27+
真实定时器下不留 ref'd 定时器、fake timers 下连跑多轮不累积(计数能看见 `unref()` 过的定时器,
28+
因此识破 `unref()` 式的假修复)、以及被守护方真的挂住时超时照常上报。
29+
30+
超时时长(`timeoutMs` / `shutdownTimeout`)一个都没动 —— 问题从来不在时长,而在没人回收。
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): resolve the `NOW()` defaultValue token in the engine, so it works on every datasource (#4597)
6+
7+
`Field.datetime({ defaultValue: 'NOW()' })` only ever worked on SQL. The engine's
8+
`applyFieldDefaults` special-cased exactly two `defaultValue` shapes — the
9+
Expression envelope and the `current_user` token — and passed everything else
10+
through verbatim, so the four characters `NOW()` were written into the record as
11+
a **literal string**. The SQL driver hid that: `SqlDriver.formatInput` carries an
12+
insert-time safety net that swaps any `NOW()` string for a real ISO timestamp
13+
before it hits the wire. Memory and MongoDB have no such net.
14+
15+
This is the mirror image of #4560. There, `current_user` was known to the engine
16+
and not to the DDL, so the DDL stored the token text. Here, `NOW()` was known to
17+
the SQL driver and not to the engine — same crack, opposite side. It surfaced two
18+
ways:
19+
20+
- On a **validated** field the insert was **rejected outright**, by the engine's
21+
own write validator, against a value the engine itself had just filled in:
22+
`ValidationError: … must be a valid datetime (ISO-8601)`. Every insert omitting
23+
such a field failed, with an error naming a field the caller never sent.
24+
- On a `readonly` / `system` field — which `validateRecord` skips, i.e. the ~100
25+
`created_at` / `updated_at` declarations across the platform objects — nothing
26+
was rejected at all and the string `NOW()` was **stored**.
27+
28+
`applyFieldDefaults` now resolves the token itself, from the same per-insert
29+
`now` snapshot it already passes to Expression defaults, so every field defaulted
30+
in one insert (and every row of one batch) carries the identical instant. The
31+
spelling it matches is the spec's (`isNowDefaultToken` from
32+
`@objectstack/spec/data`, case-insensitive and whitespace tolerant), the same
33+
predicate a driver's DDL consults — the engine does not re-derive its own.
34+
35+
The token resolves into the shape the field's **declared type** stores, which is
36+
what `SqlDriver.nowColumnDefault` already emits per type (ADR-0053), so no
37+
datasource disagrees about the stored form:
38+
39+
| field type | stored value |
40+
|---|---|
41+
| `date` | `YYYY-MM-DD` (UTC calendar day) |
42+
| `time` | `HH:MM:SS[.fff]` (UTC wall clock; a zero `.000` is trimmed) |
43+
| `datetime`, and any non-temporal field that opts in | `YYYY-MM-DDTHH:MM:SS.sssZ` |
44+
45+
No authoring change: `defaultValue: 'NOW()'` is the same declaration it always
46+
was, and a caller-supplied value is still never overwritten. What changes is that
47+
it now means the same thing on memory and MongoDB as it always did on SQL.
48+
Records written on a non-SQL datasource before this fix may hold the literal
49+
string `NOW()` in those columns; they are not rewritten.
50+
51+
Both driver-side mechanisms stay, unchanged, as defence in depth: `formatInput`'s
52+
safety net (now unreachable from the engine's insert path) and the native column
53+
DEFAULT, which still serves writes that bypass the engine entirely — the same
54+
division of labour `current_user` has.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint): `validateOrgAxisRedLines` reads the sharing-rule keys the spec declares — the ADR-0105 D6 red lines fired on nothing before (#4984)
6+
7+
The two ADR-0105 D6 red lines are declared `error` and gate `os validate` /
8+
`os build` / `os lint`. On the sharing-rule path neither of them could fire.
9+
10+
`validateOrgAxisRedLines` read `rule.criteria ?? rule.filter` and
11+
`rule.sharedTo ?? rule.recipient`. `SharingRuleSchema` is `.strict()` and its
12+
declared keys are `condition` and `sharedWith`; all four names it was reading
13+
exist only as **rejected aliases** in `sharingRuleUnknownKeyError`, the
14+
prescription attached to the refusal message. The rule runs on the post-parse
15+
stack (`input: 'parsed'`), so for every spec-valid stack those four properties
16+
were `undefined`, `JSON.stringify(undefined ?? '')` was `'""'`, and the
17+
`parent_organization_id` test was constantly false.
18+
19+
**This is a behaviour change: the rule previously never triggered.** Both red
20+
lines are now live on the sharing-rule path:
21+
22+
| Authored shape | Before | After |
23+
|:--|:--|:--|
24+
| `condition` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].condition` |
25+
| `sharedWith` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].sharedWith` |
26+
| `sharedWith: { type: 'business_unit' }` on a `tenancy.enabled: false` object | passed | `error` `org-axis-cross-org-bu-grant` at `sharingRules[i].sharedWith` |
27+
28+
A stack that ships today keeps building unless it contains one of those three —
29+
the shapes D6 forbids and the gate was meant to have been refusing all along.
30+
The rejected aliases are deliberately **not** read: a rule spelling `criteria`
31+
or `sharedTo` is refused by the schema's own parse with the canonical key
32+
named, and a consumer must not tolerate what the producer's contract rejects.
33+
34+
`condition` is an `ExpressionInput`, so all three of its shapes are scanned —
35+
the authored bare string, the parsed `{ dialect, source }` envelope, and the
36+
compiled `{ dialect, ast }` form.
37+
38+
The rule's own tests were the reason this survived review: their fixtures used
39+
the same rejected aliases, so the suite was green while the gate was dead. Every
40+
sharing-rule fixture now goes through `SharingRuleSchema` before the lint sees
41+
it, and every object fixture through `ObjectSchema` — a fixture that drifts from
42+
the spec surface fails at the fixture instead of silently exercising a shape no
43+
author can write.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): reject a sharing-rule condition the runtime can only skip (#4698)
6+
7+
#4698 reported the same failure shape three times in one app in one day: a key
8+
that is authored, is schema-valid, reads as meaningful — and is never consumed
9+
by the runtime. Every check verifies that what is declared is *well-formed*,
10+
never that it is *read*. The issue's third measured instance is a sharing rule
11+
whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the
12+
rule, and the only signal is one WARN line at boot. The rule exists in
13+
metadata, is absent from `sys_sharing_rule`, and grants nothing.
14+
15+
**New rules, both `error`, on all three authoring commands:**
16+
17+
- **`sharing-rule-unlowerable-condition`** — the condition is outside the
18+
pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a
19+
ternary, or a cross-object path (`record.account.region`).
20+
- **`sharing-rule-runtime-variable-condition`** — the condition reads
21+
`current_user.*`. Criteria sharing rules are materialised (one static
22+
`criteria_json` per rule, from which grants are written), so there is no
23+
"current user" at compile time. The fix is a different mechanism, not a
24+
different spelling, which is why it has its own id.
25+
26+
Fix each by rewriting the predicate inside the lowerable subset — `==` `!=`
27+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
28+
`startsWith` / `endsWith` / `contains` over single-column `record.<field>`
29+
paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x !=
30+
null` (`has()` is correct in an object *validation* rule, which is
31+
interpreted, and wrong here, where the condition is compiled); and a related
32+
record's field → denormalise it onto this object (formula/rollup) and test
33+
that column, or share the related object instead. For per-user access, use an
34+
RLS policy (`rowLevelSecurity[].using`), where `current_user.*` *is* resolved.
35+
36+
**Why this one surface and not "unread keys" in general.** "Is this key read?"
37+
is only a lint question when the answer is computable from the authored
38+
metadata alone, and usually it is not — a repo-wide grep for a reader is not
39+
evidence of absence, and a consumer may live in another package, another repo,
40+
or an uninstalled plugin. A sharing rule's `condition` is the case where the
41+
predicate is exact: its one runtime consumer
42+
(`bootstrapDeclaredSharingRules`) does exactly one thing with the key —
43+
`compileCelToFilter(condition, { variables: {} })` — and a condition that does
44+
not lower means the rule is skipped outright. So the lint calls that same
45+
compiler, from the same package, with the same options, instead of modelling
46+
the consumer; the verdict is identical to the seeder's by construction and is
47+
pinned in both directions by a test over a shared corpus.
48+
49+
`error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own
50+
docblock makes ("the whole authorable surface is enforced — nothing here
51+
validates and then silently does nothing"): there is no reading under which an
52+
unlowerable condition does what it says. It fails closed, which is why it was
53+
survivable, not why it was acceptable. Measured before shipping: every
54+
sharing-rule condition declared anywhere in this repo lowers cleanly, so the
55+
gate turns nothing red that works today.
56+
57+
CEL *syntax* errors are deliberately left to `expression-invalid`, which
58+
already gates this same field with a message written about syntax.

0 commit comments

Comments
 (0)