Skip to content

Commit a6269de

Browse files
committed
Merge main (#4677 landed as a squash commit)
2 parents fbadb90 + f2445c9 commit a6269de

91 files changed

Lines changed: 4105 additions & 409 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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/core": minor
5+
"@objectstack/metadata-protocol": patch
6+
---
7+
8+
feat(core,platform-objects,spec): the ADR-0119 D2 migration-journal runner — a migration killed mid-run is resumable to completion or compensable to clean, with journal rows proving which (#4617)
9+
10+
**The gap D1 left open.** ADR-0119 D1 made `engine.transaction()` reachable
11+
through the contract, which is the right answer for multi-write atomicity that
12+
fits in one transaction. Migration-class work does not fit: a million-row
13+
backfill cannot hold one write-lock for its duration, `driver-memory`'s
14+
`beginTransaction` deep-clones the entire database (O(db) per begin),
15+
`ObjectQL.transaction()` binds the **default driver only** so a multi-datasource
16+
migration silently commits part of its work outside it, and a process **killed**
17+
— as distinct from a thrown error — defeats in-process rollback entirely. So the
18+
unit of atomicity is the *chunk*, and durability across chunks is a journal.
19+
20+
Four consumers had each converged on the same four moves — dry-run preflight,
21+
undo journal, LIFO compensation, re-entrant forward recovery (ADR-0105 D13
22+
promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, and
23+
D10 master-data distribution #4585). One copy is engineering; four is platform
24+
debt, and the fourth author would have had to rediscover the invariant below
25+
from scratch.
26+
27+
**New: `runMigrationJournal` (`@objectstack/core`).** Preflight runs every
28+
step's read-only validator before any step writes, so a plan that would fail at
29+
step 3 has not written step 1. Rows are chunked per the `bulk-write.ts`
30+
discipline; each chunk's writes run inside `engine.transaction()`. On failure,
31+
committed chunks are compensated newest-first, each in its own transaction. On
32+
restart, a rediscovered run resumes forward from the first chunk lacking
33+
`chunk_done`, or unwinds, per the plan's `onCrash` policy. Forward and
34+
compensate callbacks receive an `attempt` counter; `attempt > 1` means the prior
35+
outcome is UNKNOWN and the callback must recheck by natural key before
36+
re-writing — the same at-least-once contract `bulk-write.ts` already documents,
37+
reused rather than re-derived.
38+
39+
**The invariant that carries the design:** `chunk_done(i)` is written **inside**
40+
the chunk's own transaction, so `done ⇔ committed` holds by construction;
41+
`chunk_started(i)` is written autonomously **before** it. That asymmetry is what
42+
gives `started ∧ ¬done` exactly one meaning — *the outcome is unknown* — which
43+
is the only state a crash can leave and the only state recovery reasons about.
44+
Making both writes symmetric would look tidier and would destroy recovery.
45+
46+
**New: `sys_migration_journal` (`@objectstack/platform-objects`).** Rows keyed
47+
`(run_id, seq)` under a unique index, so a resumed run that miscomputes its next
48+
sequence fails loudly rather than double-recording an event. Registered
49+
unconditionally alongside `sys_migration` because recovery must be discoverable
50+
with **zero host wiring** — a journal some kernels compose and others do not is
51+
a journal a boot scanner cannot rely on (ADR-0078). Distinct in grain from
52+
`sys_migration`, which holds one durable verdict per named migration; this holds
53+
many rows per *run*. Read-only over the API; writes go through the runner in
54+
system context.
55+
56+
**The runner refuses rather than degrades**, in four places: the runtime cannot
57+
roll back; any preflight fails; the plan declares `onCrash: 'compensate'` but a
58+
step cannot compensate; or a resume's plan hash disagrees with the journal
59+
(resuming a changed plan would apply chunk boundaries the journal never
60+
described). A compensation failure halts and is journalled — never swallowed —
61+
and the run ends `failed`, not `compensated`, because a database in a state no
62+
clean story covers must not be reported as a tidy rollback.
63+
64+
**`engineCanRollBack` is now shared.** The two-level probe (engine method AND
65+
default-driver `beginTransaction`) was the same condition written twice — here
66+
and in `batchData`'s atomic gate. It now lives in `@objectstack/core` and
67+
`@objectstack/metadata-protocol` imports it, as a type predicate so callers do
68+
not each re-narrow the optional member by hand. Two copies of "can this runtime
69+
actually roll back?" drift by one clause and leave one caller believing it has
70+
atomicity it does not have.
71+
72+
Boot reconciliation and `os migrate resume` land separately; `findInterruptedRuns`
73+
is the discovery primitive they will consume, and is exported here.
74+
75+
**Docs:** ADR-0118 (plugin-reachable transactions) is renumbered **ADR-0119**.
76+
It merged one day after an unrelated ADR-0118 (非用户 actor 的平台契约) and the
77+
earlier merge holds the number; citations of "ADR-0118 D1/D2/D3/D4" written
78+
before 2026-08-03 mean the renumbered record.

.changeset/adr-0118-plugin-reachable-transactions.md renamed to .changeset/adr-0119-plugin-reachable-transactions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"@objectstack/metadata-protocol": minor
44
---
55

6-
feat(spec,metadata-protocol): `IObjectQLEngine.transaction` joins the slot contract, and `batchData`'s `atomic` flag becomes real — rollback or refusal, never silent best-effort (ADR-0118 D1/D4, #4612)
6+
feat(spec,metadata-protocol): `IObjectQLEngine.transaction` joins the slot contract, and `batchData`'s `atomic` flag becomes real — rollback or refusal, never silent best-effort (ADR-0119 D1/D4, #4612)
77

88
**D1 — the contract fix.** `ObjectQL.transaction()` — ADR-0034's ambient
99
transaction, shipped since v8.0.0 — was reachable from plugin space only
@@ -53,7 +53,7 @@ If you were passing `atomic: true` and relying on partial results surviving a
5353
failure, that was the bug — switch to `atomic: false` (or omit it) for
5454
best-effort semantics.
5555

56-
ADR-0118 also rules on two items landing separately: D2 specifies a
56+
ADR-0119 also rules on two items landing separately: D2 specifies a
5757
framework-owned migration-journal runner for multi-step migrations too large
5858
for one transaction, and D3 retires the declared-but-unimplemented
5959
`IDataEngine.batch?`.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
fix(plugin-security): the org-admin auto-grant can actually revoke — demoted admins really do lose tenant admin (#4640)
6+
7+
`auto-org-admin-grant`'s only delete channel called
8+
`ql.delete(object, id, { context })`. The engine's signature is two arguments —
9+
`delete(object, options?: EngineDeleteOptions)` — so the id landed in the option
10+
bag, `rejectUnknownEngineOptions` read its character indices (`'0'`, `'1'`, …)
11+
as unknown option keys and threw, and `tryDelete`'s `catch` swallowed it. The
12+
system context in the discarded third argument went with it.
13+
14+
That wrapper is the module's **only** delete channel, so all three revoke paths
15+
were silent no-ops for the module's entire life:
16+
17+
1. **Demotion and member removal did not take the capability back.**
18+
`organization/update-member-role` moving someone from `owner`/`admin` back to
19+
`member` reconciled, deleted nothing, and returned
20+
`{ action: 'skipped', reason: 'delete_failed' }` while the
21+
`sys_user_permission_set` row stayed put. That row carries wildcard
22+
`viewAllRecords`/`modifyAllRecords` → `isTenantAdmin()`, so the demoted user
23+
remained a **tenant admin**.
24+
2. **The ADR-0105 D4 superseded-variant convergence never converged.** A posture
25+
change left the old `organization_admin` / `organization_admin_no_bypass` row
26+
in force — on a wall-less deployment, that is the unbounded variant.
27+
3. **The `kernel:ready` orphan sweep never swept** (membership deleted, grant
28+
left behind).
29+
30+
The call now matches every other `ql.delete` call site in the repo:
31+
`ql.delete(object, { where: { id }, context: SYSTEM_CTX })`.
32+
33+
## ⚠️ Behaviour change: people will lose tenant admin on upgrade — that is the fix working
34+
35+
Existing deployments have accumulated `sys_user_permission_set` rows that should
36+
have been revoked when someone was demoted or removed from an organization.
37+
After this release the `kernel:ready` backfill reconciles them, and every one of
38+
those grants is deleted on the first boot. Concretely, on upgrade:
39+
40+
- users demoted from `owner`/`admin` to `member` at any point in the past
41+
**stop being tenant admins**;
42+
- users whose membership was deleted lose their orphaned org-scoped grant;
43+
- deployments that changed `tenancy.posture` converge on the posture's variant
44+
instead of keeping both.
45+
46+
Nobody loses access they were *supposed* to have: the grade that qualified them
47+
was already taken away, and only the capability row outlived it. If a specific
48+
person should keep blanket visibility, grant it deliberately —
49+
`admin_full_access` or an explicitly authored permission set — rather than
50+
through a better-auth membership grade. Expect `[security] revoked org-admin
51+
capability` lines in the boot log naming each one.
52+
53+
Failed revokes are no longer silent either: a delete the datastore rejects logs
54+
`[security] org-admin grant revoke FAILED — capability still in force`, and a
55+
reconcile that found grant rows and removed none logs that it left them behind.
56+
A capability the platform decided to withdraw and could not is exactly the
57+
outcome that must reach an operator.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: 双源 C5 收敛 — `ActivationEventSchema` 归 `./kernel` 结构化形状,`./studio` re-export (#4653)
6+
7+
`ActivationEventSchema` 这个名字过去在两个入口解析到**两份不同的声明**,插件作者拿到哪套校验取决于他从哪个子路径 import(#4411 陷阱):
8+
9+
| 入口 | 声明 | 作者写的样子 |
10+
|:--|:--|:--|
11+
| `@objectstack/spec/kernel` | `z.object({ type: z.enum([...]), pattern: z.string() })` | `{ type: 'onCommand', pattern: 'my.cmd' }` |
12+
| `@objectstack/spec/studio` | `z.string()` | `'onCommand:my.cmd'` |
13+
14+
两侧都在作者面上(kernel 侧嵌在 `DynamicLoadRequest.activationEvents`,studio 侧嵌在 `StudioPluginManifest.activationEvents`,后者正是 `defineStudioPlugin` 的入参),所以没有"死侧"可删。v17 统一到**结构化形状**:`./studio` 现在 re-export `./kernel` 的那一份声明,平台只剩一套激活词表。
15+
16+
**为什么是结构化的那一侧赢。** 字符串那一侧更眼熟(照搬 VS Code),但它什么都不校验:`z.string()` 接受 `''`、`'banana'`,以及真正要命的 `'onMetadatType:flow'` —— 这个文件文档里列的词表(`*`、`onMetadataType:`、`onCommand:`、`onView:`)只活在散文里,拼错永远静默通过。结构化形状用 enum 在**创作时**就把触发器类型钉死,这才是声明它的意义。
17+
18+
## FROM → TO
19+
20+
`activationEvents` 的每一项从字符串变成对象。冒号前的段成为 `type`,冒号后的段成为 `pattern`:
21+
22+
```ts
23+
// FROM (v16 及以前,@objectstack/spec/studio)
24+
defineStudioPlugin({
25+
id: 'objectstack.flow-designer',
26+
name: 'Flow Designer',
27+
activationEvents: ['onMetadataType:flow'],
28+
});
29+
30+
// TO (v17+)
31+
defineStudioPlugin({
32+
id: 'objectstack.flow-designer',
33+
name: 'Flow Designer',
34+
activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }],
35+
});
36+
```
37+
38+
逐条对照:
39+
40+
| FROM | TO |
41+
|:--|:--|
42+
| `'*'` | `{ type: 'onStartup', pattern: '*' }` |
43+
| `'onMetadataType:flow'` | `{ type: 'onMetadataType', pattern: 'flow' }` |
44+
| `'onCommand:myPlugin.doSomething'` | `{ type: 'onCommand', pattern: 'myPlugin.doSomething' }` |
45+
| `'onView:myPlugin.myPanel'` | `{ type: 'onView', pattern: 'myPlugin.myPanel' }` |
46+
47+
`StudioPluginManifest.activationEvents` 的默认值随之从 `['*']` 变为 `[{ type: 'onStartup', pattern: '*' }]`。`'*'` 没有拿到独立的 `type`:它一直就是"立即激活",而 kernel 侧的 `onStartup` 本来就是这个意思,再加一个枚举值只会造出两个同义词。
48+
49+
## 词表 = 两侧并集,没有能力被静默拿掉
50+
51+
enum 取**两侧 v17 前词表的并集**,共 9 个值:
52+
53+
| 值 | 来源 |
54+
|:--|:--|
55+
| `onCommand` | kernel enum + studio 文档 `onCommand:myPlugin.doSomething` |
56+
| `onRoute` | kernel enum |
57+
| `onObject` | kernel enum |
58+
| `onEvent` | kernel enum |
59+
| `onService` | kernel enum |
60+
| `onSchedule` | kernel enum |
61+
| `onStartup` | kernel enum;同时是 studio `'*'` 的落点 |
62+
| `onMetadataType` | studio 文档/测试 `onMetadataType:object` —— kernel 原本没有 |
63+
| `onView` | studio 文档/测试 `onView:myPlugin.myPanel` —— kernel 原本没有 |
64+
65+
**未采纳**:cloud-v1 未发布的 marketplace runtime 里的 `priority`、`onInstall`、`onWebhook`。四仓无人读它们,而新增一个 declared-but-unenforced 的键正是 ADR-0049 在清的债 —— 等真有执行点再单独提。
66+
67+
## 迁移是手工的,但失败是响亮的
68+
69+
**没有随附 ADR-0087 conversion,因为写不出能跑到的那一个。** conversion 层(`applyConversions`)接在 `normalizeStackInput` 上,只走 stack 树;而 `StudioPluginManifestSchema` 和 `DynamicLoadRequestSchema` 都是**根 schema**,没有任何父 schema 嵌入它们(前者由 `defineStudioPlugin` 直接 parse,后者是运行时请求载荷),都不在 stack 里。伪造一个永远不会命中的 conversion 只会制造"已自动迁移"的假象。
70+
71+
手工迁移步骤:按上表把每个字符串改写成 `{ type, pattern }`。**漏改会在 parse 处响亮失败** —— `StudioPluginManifestSchema` 是 `strictObject`,字符串遇到对象 schema 直接抛错,不存在静默吞掉或强制转换。
72+
73+
## 不要与同窗口的 #4509 / #4664 退休项混淆
74+
75+
v17 同窗口的 #4664 退休了五个键。其中 **`app.contextSelectors[].placement`** 与本条变更**毫无关系**,但很容易被读成有关系 —— 那条退休说明里写着「`location` 曾是 `placement` 的别名」,而 Studio 插件的面板贡献点**恰好也有一个 `location` 键**:
76+
77+
| | 被 #4664 退休的 | 本次变更**未动**的 |
78+
|:--|:--|:--|
79+
| 键 | `ui/App.contextSelectors[].placement`(`location` 是它的别名) | `studio/PanelContribution.location` |
80+
| 语义 | app 的上下文选择器渲染在哪(`sidebar_header` / `topbar`) | Studio 插件的辅助面板停靠在哪(`bottom` / `right` / `modal`) |
81+
| 状态 | 已删除 | **原样保留**,仍是可作者化键 |
82+
83+
两者在不同 schema 上、取值域不同、互不相关。写 Studio 插件的作者**不需要**因为 #4664 去动 `contributes.panels[].location`。
84+
85+
其余四个退休键(`mapping.extractQuery` / `mapping.errorPolicy` / `mapping.batchSize` / `app.contextSelectors[].includeAll`)与 `activationEvents` 无任何语义交叉;同窗口的 #4668(ADR-0119 D2 migration journal)亦然。
86+
87+
## 其它影响
88+
89+
- `@objectstack/spec/studio` 现在**额外导出** `ActivationEvent` 类型(此前只有 schema),与 `./kernel` 指向同一份声明。
90+
- `ActivationEventSchema` 从 `dual-source-exports.baseline.json` 移除,基线 22 → 21。
91+
- 零可作者化 key 消失、零 tombstone:kernel 的 `ActivationEvent:type` / `:pattern` 原样存活,`studio/ActivationEvent` 侧新增 2 个 key(字符串没有 key,对象有),属 `gen:schema` 允许的**新增**。
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/plugin-security": minor
5+
"@objectstack/plugin-approvals": minor
6+
"@objectstack/plugin-hono-server": minor
7+
"@objectstack/service-messaging": minor
8+
---
9+
10+
feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355)
11+
12+
**FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix:
13+
rename the value. Nothing else about the object changes. `os migrate meta --from 16`
14+
rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry
15+
`object-managed-by-system-to-system-data`, never silently reinterpreted.
16+
17+
ADR-0103 split the overloaded `system` bucket in v16, and it split it
18+
**additively**: the 20 engine-owned objects moved to the new explicit
19+
`engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables
20+
(`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`),
21+
`sys_user_preference`, `sys_approval_delegation`, and the three messaging config
22+
grids — stayed behind on `system`. That was the right move for a v16 that could
23+
not break authors, but it left the enum in a state where the surviving value
24+
names the half that had already moved out: `system` sitting on precisely the
25+
objects a user writes.
26+
27+
That is not a cosmetic complaint. An author choosing between `system` and
28+
`engine-owned` had nothing in the vocabulary to choose *on*, so the bucket was
29+
re-overloadable by anyone reading the name in good faith — a model author most
30+
of all, since "system table" reads as "the engine owns this" in every other
31+
codebase. `system-data` states both boundaries explicitly: the **schema** is the
32+
platform's (versus `platform`, which is tenant-modelled), the **data** is the
33+
admin's or the user's (versus `engine-owned`, where the engine owns both).
34+
35+
Because v16 already drained the engine side, the conversion is a **one-to-one
36+
mechanical value rename** with no judgement call — by construction every
37+
remaining `system` declaration is writable platform data.
38+
39+
**One deliberate consequence — the affordance default flips.** `system` defaulted
40+
LOCKED and each of the 8 objects re-opened its writes with a
41+
`userActions: { create: true, edit: true, delete: true }` block. `system-data`
42+
defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data
43+
is yours" should not make every member ask for it back. Those blocks are now
44+
redundant and have been deleted from the 8 platform objects; keep `userActions`
45+
only to **NARROW**. If you converted an object that carried no `userActions`, it
46+
gains the generic affordances — the honest reading of the bucket it moved into.
47+
48+
**No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS
49+
and permission sets all adjudicate off resolved affordances and the principal,
50+
never off the bucket name. `system-data` simply joins `platform` / `config` as a
51+
bucket the fail-closed guard does not cover, because a writable default has
52+
nothing to close on. The 8 objects passed that guard before (via `userActions`)
53+
and pass it now (via the bucket default), for the same resolved-affordance
54+
reason.
55+
56+
`'system'` is **retired from the load path**: the enum rejects it with a
57+
prescription naming `system-data` and the one-line fix. Absorbing it silently at
58+
load would leave every author still writing the name this rename exists to
59+
unteach.

0 commit comments

Comments
 (0)