Skip to content

Commit 84396f2

Browse files
committed
Merge origin/main into claude/issue-4986-unique-scope-vocab
Resolves the scripts/adr-anchors.json conflict by keeping all five ADR-0120 anchor entries (spec/lint side + driver side from #5212), and clears the os-regen deferred merge of packages/spec/api-surface.json via spec build + gen:api-surface (isOrganizationUnique retained). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7
2 parents 33351e4 + 193cd5c commit 84396f2

140 files changed

Lines changed: 13934 additions & 1258 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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
fix(plugin-audit): stop mirroring `sys_job_queue` traffic into the audit ledger (#5193)
6+
7+
`SKIP_OBJECTS` in `audit-writers.ts` excludes operational telemetry / plumbing
8+
from `sys_audit_log` and `sys_activity` — ADR-0057 decision 5, *"stop the
9+
amplifier"*. Its group (2) already listed `sys_job`, `sys_job_run` and
10+
`sys_automation_run`; `sys_job_queue` — the highest-volume table of that same
11+
family — was the one sibling missing, so every durable queue message was
12+
mirrored into both sinks.
13+
14+
The audit hooks register for **all** objects (`afterInsert` / `afterUpdate` /
15+
`afterDelete`) and there is no "writes made under a system context are not
16+
audited" exemption, so `DbQueueAdapter`'s own writes were recorded like user
17+
edits. One message costs at least three of them — the publish insert, the lease
18+
`pending → running` update and the terminal `→ completed` update, plus one retry
19+
update per failure and the reaper's periodic DELETE of completed rows — each
20+
producing an `sys_audit_log` **and** an `sys_activity` row. Since queue-backed
21+
email delivery landed, that ran on every single mail. Each `beforeUpdate` also
22+
paid an extra `findOne` snapshot of the row it was about to change.
23+
24+
`sys_job_queue` is engine-owned plumbing (`managedBy: 'engine-owned'`,
25+
`enable.apiMethods: ['get', 'list']`, `lifecycle.class: 'transient'`) that no
26+
user can write, so those rows carried no compliance value — only noise and write
27+
amplification. Nothing else changes: the exemption is one name in one list, and
28+
ordinary business objects are audited exactly as before.
29+
30+
Operators who charted queue throughput off `sys_activity` should read
31+
`sys_job_queue` directly instead — it is the system of record for queue state,
32+
and unlike the audit sinks it is exposed for reading (`get` / `list`).
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
fix(plugin-audit): stop mirroring chunked-upload progress into the audit ledger (#5202)
6+
7+
`SKIP_OBJECTS` in `audit-writers.ts` excludes operational telemetry / plumbing
8+
from `sys_audit_log` and `sys_activity` — ADR-0057 decision 5, *"stop the
9+
amplifier"*. `sys_upload_session` was the second table missing from group (2)
10+
for the same reason `sys_job_queue` was (#5193): it declares
11+
`lifecycle.class: 'transient'` and its own object comment says what the rows are
12+
worth — *"an upload session is ephemeral state, never business truth"*
13+
(ADR-0057 / #2970 item 4) — but nothing connected that declaration to the
14+
exemption list, which is hand-written.
15+
16+
The audit hooks register for **all** objects and there is no "writes made under
17+
a system context are not audited" exemption, so `StorageMetadataStore`'s own
18+
writes were recorded like user edits. A chunked upload of N parts costs 1 + N
19+
writes — the `createSession()` insert plus one `updateSession()` per chunk — and
20+
then a terminal status update and the row's removal, each producing an
21+
`sys_audit_log` **and** an `sys_activity` row: 2 × (1 + N) rows for one file,
22+
with a `beforeUpdate` snapshot read apiece. Each of those rows was also unusually
23+
fat, because `updateSession()` writes the merged **full** record, so the `parts`
24+
JSON blob that grows with every chunk rode along in each diff's `old_value` /
25+
`new_value`.
26+
27+
Nothing else changes: the exemption is one name in one list, and ordinary
28+
business objects are audited exactly as before. In particular `sys_file` stays
29+
audited — it declares `transient` too, but only to reap tombstones and
30+
unfinished uploads; its rows are mostly permanent business truth and keep their
31+
compliance value.
32+
33+
Operators who tracked upload activity through `sys_activity` should read
34+
`sys_upload_session` (in-progress state) and `sys_file` (the durable record of
35+
what was actually stored) instead.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/service-settings": minor
4+
---
5+
6+
feat(auth): `membership_policy` is a platform setting, and sign-up and backfill read one source (#5152)
7+
8+
**What a new user joins is now configurable at runtime.** ADR-0093's
9+
`membershipPolicy` decides whether a freshly created user is auto-bound to the
10+
deployment's default organization (`auto`) or gets membership only from an
11+
explicit act — creating a workspace, accepting an invitation, an admin adding
12+
them, SSO just-in-time provisioning (`invite-only`). Until now it was settable
13+
**only** as an `AuthPlugin` constructor option, and the AuthPlugin a self-hosted
14+
stack gets is injected by the CLI, which passes no such option and has no env
15+
fallback. Every self-hosted deployment therefore ran `auto`, with no way to say
16+
otherwise. `invite-only` was, in practice, unreachable outside a custom host.
17+
18+
It is now `auth.membership_policy` in the platform settings — a two-value select
19+
(`auto` / `invite-only`, default `auto`) alongside `signup_enabled`, which it
20+
pairs with: one says whether people may self-register, the other says what they
21+
join when they do. Set it in Setup → Authentication → Membership, or pin it
22+
per-deployment with `OS_AUTH_MEMBERSHIP_POLICY`. It applies **without a
23+
restart** — the existing `settings.subscribe('auth', …)` re-application seam
24+
carries it, the same one the password-policy keys ride.
25+
26+
**No behaviour changes unless you set it.** Only an *explicit* value applies;
27+
the manifest's `auto` default is a UI default and never masks a deployment that
28+
configured the policy in code. A stack that sets nothing keeps today's
29+
auto-binding exactly.
30+
31+
**Bug fix — the two membership paths read one source.** Sign-up (the reconciler
32+
in better-auth's `user.create.after`) read the AuthManager's live config, while
33+
the ADR-0093 D6 backfill of pre-existing member-less users read the plugin's
34+
**constructor options**. Wiring a setting to the first and not the second would
35+
have produced "sign-up honours the new policy, backfill still runs the old one"
36+
— and the backfill binds in **bulk**, so it is the more dangerous half. Both now
37+
resolve the policy through the new `AuthManager.getMembershipPolicy()`, and the
38+
backfill waits for the settings namespace to bind before its first pass (the two
39+
`kernel:ready` hooks fire in registration order, which was the wrong order).
40+
41+
**An invalid value is rejected, not coerced.** `PUT /api/settings/auth` refuses
42+
a policy outside the declared option table (`invalid_option`, naming the allowed
43+
set). A value arriving from `OS_AUTH_MEMBERSHIP_POLICY` — which bypasses that
44+
validation — is logged at `error` and **ignored**, leaving the deployment's
45+
current policy in force; it is never silently read as `auto`, because that would
46+
leave an operator believing a wall is up while every sign-up is auto-bound.
47+
48+
New public API on `@objectstack/plugin-auth`: `AuthManager.getMembershipPolicy()`,
49+
plus `MEMBERSHIP_POLICIES` and `isMembershipPolicy()` from `reconcile-membership`.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022)
7+
8+
`drillDown` has driven a real capability since long before this release: click a
9+
bar or a slice on an `<ObjectChart>` and objectui opens the underlying records,
10+
filtered by the clicked category, in a drawer. The protocol declared it
11+
**nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside
12+
it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was
13+
simply ignored at click time. This is Prime Directive #10 inverted: not declared
14+
without being delivered, but delivered without ever being declared.
15+
16+
It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing
17+
that parsed before stops parsing.
18+
19+
## What you can write
20+
21+
`drillDown` is a prop on the react-tier `<ObjectChart>` block:
22+
23+
```jsx
24+
<ObjectChart objectName="opportunity"
25+
aggregate={{ function: 'sum', field: 'amount', groupBy: 'stage' }}
26+
drillDown={{ columns: ['name', 'amount'], maxRows: 50 }} />
27+
```
28+
29+
| key | type | meaning |
30+
|---|---|---|
31+
| `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it |
32+
| `filter` | `Record<string, unknown>` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category |
33+
| `title` | `string` | Drawer/dialog heading; supports `${event.*}` |
34+
| `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer |
35+
| `columns` | `string[]` | Column whitelist for the drilled list |
36+
| `maxRows` | `number` | Rows per page in the drilled list |
37+
38+
Every one of those six is a key objectui's `ObjectChart` was measured to read.
39+
The renderer's own drill type is wider — it is shared with the table / pivot /
40+
metric widgets — and the extra keys are **deliberately not declared**, because a
41+
chart reads none of them:
42+
43+
- **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment
44+
is always an aggregate, so there is nothing to discriminate.
45+
- **`report`** (drill into a report instead of a record list) is a metric/pivot
46+
capability.
47+
- **`view`** and **`sort`** are read by *no* renderer at all (objectui#3354).
48+
- **`target: 'navigate'`** is implemented for the other widgets but not for a
49+
chart, which falls back to the drawer.
50+
51+
Writing any of them is now a loud rejection that says which surface owns it,
52+
rather than a value that silently does nothing.
53+
54+
## Where it is NOT declared, and why that is deliberate
55+
56+
**Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard
57+
widget has no per-widget drill configuration, by design: an ADR-0021
58+
dataset-bound widget drills through the semantic layer, deriving the target
59+
object and filter from the dataset row that was clicked. That is what
60+
`content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer
61+
does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget`
62+
forwards exactly one key out of it (`showLegend`). Declaring the drill there
63+
would have produced authorable metadata that parses clean and never reaches a
64+
renderer — the failure this campaign removes elsewhere.
65+
66+
So the three places an author might reach for it now answer instead of shrugging:
67+
68+
- `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop.
69+
- `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard
70+
drill-through is **automatic**, and naming both configurable drills.
71+
- `report.drillDown` → rejected, pointing back at the chart prop.
72+
73+
## `drillDown` is not `drilldown`
74+
75+
Two capabilities, one letter apart, and they are now disambiguated in both
76+
directions at the schema gate:
77+
78+
| | `drillDown` | `drilldown` |
79+
|---|---|---|
80+
| spelling | camelCase | all lowercase |
81+
| type | configuration object | boolean |
82+
| surface | react `<ObjectChart>` prop | `ReportSchema` key (ADR-0021 D2, on by default) |
83+
84+
Edit distance alone gets this wrong — the two spellings are a distance of 1, so a
85+
plain "did you mean" would happily send an author writing `drillDown` on a report
86+
to `drilldown`, where their config object then fails a second time as a boolean.
87+
Both gates name the **type** difference, not just the spelling.
88+
89+
## Enforced, not just declared
90+
91+
`@objectstack/lint`'s react-page publish gate now **parses** the schema
92+
(`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal,
93+
rather than re-deriving the rules. Unknown keys, the wrong `target`, and the
94+
near-key spelling all fail the build with the schema's own prescription. A value
95+
assembled from React state is skipped, unchanged: an unresolvable binding is not
96+
a wrong one (ADR-0072 D1).
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): 集群对端的元数据写入现在会失效本节点的 `listCache` / registry (#5109)
6+
7+
多节点部署下,节点 A 改一条 `view` / `permission` / `flow`,节点 B 收到
8+
`metadata.changed` 广播后**只叫醒了 watcher,却没有失效自己的缓存**
9+
`attachClusterPubSub()` 的订阅回调此前只做一件事 —— `notifyWatchersLocal()`,
10+
既不碰 `this.registry` 也不碰 `this.listCache`。后果是 B 上任何走 `list(type)`
11+
的读在 `LIST_CACHE_TTL_MS`(30 秒)窗口内继续返回改动前的清单;更糟的是,被叫醒的
12+
watcher(ObjectQL SchemaRegistry 桥、Studio HMR SSE)如果回头调 `list()` 重新拉取,
13+
拉到的还是旧的 —— 一份「失效通知」附带着失效数据。单机部署完全无感,只有多节点才暴露。
14+
15+
这与该通道自己声明的用途相反(`ClusterMetadataChangedPayload`:"consumed by peers
16+
to **invalidate their local caches**",另见 `content/docs/kernel/cluster.mdx` §6.2
17+
`metadata-lifecycle.mdx`);现在实现与声明一致。
18+
19+
修法沿用同文件里 `applyRepoEvent()` 自 ADR-0008 PR-6 起就用对的那条路径,并把两条
20+
「外部写入」缝(仓库 watch 循环、集群对端回放)收敛到同一个私有方法
21+
`invalidateForForeignWrite(type, name)`:
22+
23+
- **删除而不预填。** 即便事件带着 body,也只删除 registry 条目而不写入 ——
24+
那份 body 是别人那次写入的快照,可能已被后续写入取代,预填会与真实 head 竞态,
25+
并要求我们去规范化一份自己没有加载过的定义。删除后 `get()` 自然穿透到 loader /
26+
repository,也就是真相所在。
27+
- **同步失效,先失效再通知。** 失效发生在收到消息的当拍(不在 `setImmediate` 内),
28+
通知仍然延迟派发。`setImmediate` 的存在理由是不让**消费方的 watcher 回调**背压
29+
pubsub 派发循环;而失效只是两次 `Map.delete`,不执行任何消费方代码,没有需要延迟的
30+
东西——把它一起延迟只会留下「已收到广播、尚未失效」的读窗口,请求处理器里任何一个
31+
`await` 都足以撞进去。先失效后通知也与本文件其他写入路径
32+
(`register` / `unregister` / `applyRepoEvent`)一致,于是回头 `list()` 的 watcher
33+
拿到的是写后清单。
34+
- **无名事件只失效清单缓存。** `MetadataWatchEvent.name` 在 spec 里是可选的,无名事件
35+
无法定位 registry 条目;此时不会把整个 type 的 registry 一并清掉 —— 那会驱逐
36+
`registerInMemory()` 注册的、任何 loader 都无法恢复的代码态构件(如 `origin:'code'`
37+
的 datasource)。
38+
39+
回环抑制(`originNode`)仍然先于失效判断,本节点自己的广播不会让自己白白重建缓存。
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): `DatabaseLoader` 的读故障不再被吞成「什么都没声明」(#5108)
6+
7+
`DatabaseLoader` 的五个读方法此前都把**任何**存储异常 `catch {}` 成各自的空值 ——
8+
`load``null``loadMany``[]``exists``false``stat``null`
9+
`list``[]`。于是 `sys_metadata` 所在库不可达时,`loadMany('permission')`
10+
「这个环境一条 permission 都没声明」返回**完全一样的值**,而且异常是在 loader 内部
11+
就被抹掉的:`MetadataManager` 那几个 `try/catch` 降级分支拿到的是一次「成功的空读」,
12+
根本不会触发,整条链上没有任何一处会说出「读失败了」。
13+
14+
现在按**错误类型**判决(#4632 立的规矩,#4728 / #4825 已经在同一个文件里用过两次的
15+
形状,判据复用现成的 `isMissingTableError`):
16+
17+
- 唯一良性的失败原因是 `sys_metadata` 尚未 provisioned —— 那时确实没有行,
18+
「什么都没声明」就是事实,首次启动照旧返回空值、不报错、不缓存;
19+
- 其余全部原因(连接断开、超时、权限不足、查询出错)意味着行还在、只是这次没读到,
20+
一律把驱动原始异常**原样抛出**,由调用方决定降级姿态。判据保守:无法正面识别为
21+
「表不存在」的错误一律当作真故障。
22+
23+
由此上层三个已有的机制第一次真的生效:
24+
25+
- `MetadataManager.list()` 的降级分支会真的进,并且**升级到 `error`**
26+
(AGENTS.md「Degradation log levels」:系统看着正常、它声称掌握的清单其实是残缺的),
27+
日志写明后果与修法,每次故障只说一次、恢复时再说一次;`list()` 仍然尽力返回可读
28+
loader 的内容 —— 这个 best-effort 姿态是刻意保留的。兄弟方法
29+
`MetadataManager.loadMany()` 的同一条缝走同一个判决,不让同一次故障在同一个文件里
30+
报出两个级别;
31+
- `MetadataManager.loadDiagnosed()`(ADR-0110 D3)对 `DatabaseLoader` 终于能报出
32+
`degraded` / `errors`,而不是把 outage 报成 miss;
33+
- `listForIndex()` / `matchEndpoint`(#5089)契约要求「读不到存储必须抛出,不得伪装成
34+
miss(miss 会变成 404)」—— 这条此前对 `MemoryLoader` / `RemoteLoader` 有效、对
35+
`DatabaseLoader` 无效,现在对真实的 datasource loader 也成立了。
36+
37+
**行为变化**:`MetadataManager.exists()``listNames()` 本来就没有 `try/catch`,
38+
所以存储故障现在会从它们抛出,而不再静默答「不存在」/「空清单」。这正是本次修复要的
39+
姿态 —— 可用性故障不是一次「没有」。

0 commit comments

Comments
 (0)