Skip to content

Commit 68dea0b

Browse files
authored
feat(platform-objects,service-storage,cli): sys_migration 账本改由平台基础设施注册 (#4243) (#4258)
`sys_migration`(#3617 的部署级迁移标记账本)此前由 service-storage 注册,理由写在对象头注释里:「由第一个消费它的服务注册」。那在只有一个消费者时成立,但账本早已有了与存储无关的消费者——#4235 的 value-shapes 门禁扫的是 lookup/location 值形态,整条路径不碰文件;#4215 的从空创建记账同理。代价是 `data-migration-plugins.ts` 里那个绕过:一个和文件毫无关系的迁移,也得把 StorageServicePlugin 启起来,只为让内核里有这张表。 注册点改为 `PlatformObjectsPlugin`。它通过 manifest 服务注册 `SysMigration`(scope: system),并声明 `optionalDependencies: ['com.objectstack.engine.objectql']`,使两者同在时 init 顺序落在 ObjectQL 的 manifest 服务之后,不在时(纯翻译内核)照常降级。`os serve` 本就自动注入该插件,因此每个被服务的内核都带账本,与是否装了存储无关。 #4215 的从空创建记账随注册一起搬家。它留在 storage 的理由原文是「This service owns the call because it registers sys_migration」——前提搬走,调用跟着走。storage 侧只保留读者(file-references 门禁的 `isDataMigrationVerified`),reap guard 行为不变。 `buildDataMigrationPlugins()` 的耦合随之放松:默认只注册 PlatformObjectsPlugin,账本随之而来;`{ storage: true }` 才追加 settings + storage,且只有 `os migrate files-to-references` 传它,因为只有它真的要对着部署的真实存储树做对账。`os migrate value-shapes` 从此不再为一张表启动整个存储服务——该命令严格只读,`--apply` 只写标记本身。 唯一语义变化面:手工组装 StorageServicePlugin 而不组 PlatformObjectsPlugin 的嵌入式内核不再获得账本。方向安全(fail toward retention),changeset 已写明迁移句;cloud 的宿主内核走 serve 装配,不受影响。 Closes #4243 Closes #4236
1 parent 77a77fd commit 68dea0b

9 files changed

Lines changed: 330 additions & 170 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/service-storage": patch
4+
"@objectstack/cli": patch
5+
---
6+
7+
feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243)
8+
9+
The deployment-level data-migration flag ledger (`sys_migration`, #3617) was
10+
registered by `@objectstack/service-storage` as its first consumer. That was
11+
deliberate while the file migration was the only consumer, but the ledger now
12+
gates storage-independent behaviour too — `os migrate value-shapes` (#4235)
13+
and the fresh-datastore attestation (#4215) — and a non-file migration had to
14+
boot the whole storage plugin just so the kernel carried the table. Any kernel
15+
assembled without storage silently had no ledger at all, which read exactly
16+
like "migration not run" (both answer false) while actually meaning "ledger
17+
not installed".
18+
19+
The registration now lives in `PlatformObjectsPlugin`
20+
(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already
21+
auto-injects into every served kernel — so the ledger exists with the
22+
platform, independent of which optional services are composed. The
23+
fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger
24+
bookkeeping, and its old home justified itself as "the service that registers
25+
`sys_migration`". Definition ownership is unchanged (`sys_migration` stays in
26+
`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the
27+
flag helpers and readers are untouched.
28+
29+
Consequences:
30+
31+
- `@objectstack/service-storage` no longer contributes `sys_migration` to the
32+
manifest and no longer performs the fresh-datastore attestation. An embedder
33+
composing `StorageServicePlugin` on a hand-built kernel that relied on it
34+
for the ledger must compose `PlatformObjectsPlugin` (the plugin every
35+
supported assembly path already includes).
36+
- The CLI's `buildDataMigrationPlugins()` no longer boots storage for every
37+
gated migration — it registers `PlatformObjectsPlugin` always, and settings
38+
+ storage only for `os migrate files-to-references` (`{ storage: true }`),
39+
the one migration that actually reconciles against the storage adapter.

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export default class MigrateFilesToReferences extends Command {
162162
try {
163163
stack = await bootSchemaStack({
164164
databaseUrl: flags['database-url'],
165-
extraPlugins: await buildDataMigrationPlugins(),
165+
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
166166
});
167167
} catch (error: any) {
168168
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,12 +1490,15 @@ export default class Serve extends Command {
14901490
}
14911491
}
14921492

1493-
// 5c. Auto-register PlatformObjectsPlugin so platform-default
1493+
// 5c. Auto-register PlatformObjectsPlugin. It carries platform
1494+
// infrastructure every served kernel needs: the `sys_migration`
1495+
// data-migration flag ledger + fresh-datastore attestation (#4243 —
1496+
// without it the engine's deployment gates read "no ledger" and fall
1497+
// back to the lax legacy posture), and the platform-default
14941498
// translation bundles (Setup App + metadata-type configuration
1495-
// forms shipped by @objectstack/platform-objects) are contributed
1496-
// into the kernel's i18n service. Without this, Setup nav labels
1497-
// and metadata-admin form labels fall back to English literals
1498-
// even when Accept-Language requests another locale.
1499+
// forms). Without the latter, Setup nav labels and metadata-admin
1500+
// form labels fall back to English literals even when
1501+
// Accept-Language requests another locale.
14991502
const hasPlatformObjectsPlugin = plugins.some(
15001503
(p: any) => p?.name === 'com.objectstack.platform-objects'
15011504
|| p?.constructor?.name === 'PlatformObjectsPlugin'

packages/cli/src/utils/data-migration-plugins.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,42 @@
33
import { resolveStorageCapabilityArg } from '../commands/serve.js';
44

55
/**
6-
* The service plugins a gated data migration boots with, so the kernel carries
7-
* `sys_migration` (the deployment-level flag ledger) — and, for the file
8-
* migration, `sys_file` plus the deployment's REAL storage adapter.
6+
* The plugins a gated data migration boots with.
97
*
10-
* Settings first: the storage plugin re-resolves its adapter from persisted
11-
* settings when a settings service is present, which is how an S3-configured
12-
* deployment's backfill uploads land in S3 rather than on this machine.
13-
* Storage config goes through the SAME resolver `os serve` uses
14-
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
15-
* the server would. It did not, and that mattered more here than anywhere: the
16-
* file migration reconciles what records claim against what storage actually
17-
* holds, so a root that disagrees with the server's reconciles against the
18-
* wrong tree.
8+
* Every gated migration needs the `sys_migration` flag ledger (#3617), and
9+
* that is all most of them need: it is registered by `PlatformObjectsPlugin`
10+
* — platform infrastructure, present with or without any optional service
11+
* (#4243). `os migrate value-shapes` boots exactly that.
1912
*
20-
* ⚠️ `sys_migration` is registered by the STORAGE plugin (its first consumer),
21-
* which is why a migration with nothing to do with files still boots it. That
22-
* coupling is not this module's to fix — the ledger is platform infrastructure
23-
* and should not be owned by an optional service — but until it moves, every
24-
* gated migration boots the same set so they all find the same ledger. Tracked
25-
* separately; `os serve` auto-wires storage, so a served deployment always has
26-
* it registered and the runtime gates can read their flags.
13+
* Only the FILE migration (`os migrate files-to-references`) also needs
14+
* `sys_file` plus the deployment's REAL storage adapter — it reconciles what
15+
* records claim against what storage actually holds, so a root that disagrees
16+
* with the server's reconciles against the wrong tree. `storage: true` adds:
17+
*
18+
* - Settings first: the storage plugin re-resolves its adapter from
19+
* persisted settings when a settings service is present, which is how an
20+
* S3-configured deployment's backfill uploads land in S3 rather than on
21+
* this machine.
22+
* - Storage config through the SAME resolver `os serve` uses
23+
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly
24+
* where the server would.
2725
*/
28-
export async function buildDataMigrationPlugins(): Promise<unknown[]> {
26+
export async function buildDataMigrationPlugins(
27+
opts: { storage?: boolean } = {},
28+
): Promise<unknown[]> {
2929
const plugins: unknown[] = [];
30-
try {
31-
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
32-
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
33-
} catch {
34-
// optional — without it, constructor/env-driven storage config still applies
30+
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
31+
plugins.push(new PlatformObjectsPlugin());
32+
if (opts.storage === true) {
33+
try {
34+
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
35+
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
36+
} catch {
37+
// optional — without it, constructor/env-driven storage config still applies
38+
}
39+
const { StorageServicePlugin } = await import('@objectstack/service-storage');
40+
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
41+
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
3542
}
36-
const { StorageServicePlugin } = await import('@objectstack/service-storage');
37-
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
38-
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
3943
return plugins;
4044
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { PlatformObjectsPlugin } from './plugin.js';
5+
import { SysMigration } from './system/index.js';
6+
7+
/**
8+
* Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this
9+
* package has no kernel dependency and the plugin is structurally typed, so
10+
* booting a real kernel here would buy nothing.
11+
*/
12+
function makeCtx() {
13+
const services = new Map<string, any>();
14+
const hooks: Array<() => Promise<void> | void> = [];
15+
const logs: { info: string[]; warn: string[] } = { info: [], warn: [] };
16+
const ctx: any = {
17+
logger: {
18+
info: (m: string) => { logs.info.push(String(m)); },
19+
warn: (m: string) => { logs.warn.push(String(m)); },
20+
},
21+
_logs: logs,
22+
registerService: (name: string, svc: any) => { services.set(name, svc); },
23+
getService: <T>(name: string): T => {
24+
const s = services.get(name);
25+
if (!s) throw new Error(`service '${name}' not registered`);
26+
return s as T;
27+
},
28+
hook: (event: string, fn: () => Promise<void> | void) => {
29+
if (event === 'kernel:ready') hooks.push(fn);
30+
},
31+
_flushReady: async () => { for (const h of hooks) await h(); },
32+
};
33+
return ctx;
34+
}
35+
36+
describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => {
37+
it('registers SysMigration through the manifest service', async () => {
38+
const ctx = makeCtx();
39+
const manifests: any[] = [];
40+
ctx.registerService('manifest', { register: (m: any) => manifests.push(m) });
41+
42+
await new PlatformObjectsPlugin().init(ctx);
43+
44+
expect(manifests).toHaveLength(1);
45+
expect(manifests[0].id).toBe('com.objectstack.platform-objects');
46+
expect(manifests[0].scope).toBe('system');
47+
expect(manifests[0].objects).toEqual([SysMigration]);
48+
expect(manifests[0].objects[0].name).toBe('sys_migration');
49+
});
50+
51+
it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => {
52+
const plugin = new PlatformObjectsPlugin();
53+
const ctx = makeCtx();
54+
55+
await expect(plugin.init(ctx)).resolves.toBeUndefined();
56+
await plugin.start(ctx);
57+
await expect(ctx._flushReady()).resolves.toBeUndefined();
58+
});
59+
});
60+
61+
describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', () => {
62+
/** Engine fake with a `sys_migration` table and a settable newness verdict. */
63+
function engineWith(createdFromEmpty: boolean | undefined) {
64+
const rows: Array<Record<string, unknown>> = [];
65+
const engine: any = {
66+
getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined),
67+
find: async (_object: string, options: any) =>
68+
rows.filter((r) => options?.where?.id === undefined || r.id === options.where.id),
69+
insert: async (_object: string, data: any) => {
70+
rows.push({ ...data });
71+
return data;
72+
},
73+
update: async () => ({}),
74+
rows,
75+
};
76+
if (createdFromEmpty !== undefined) {
77+
engine.wasDatastoreCreatedFromEmpty = () => createdFromEmpty;
78+
}
79+
return engine;
80+
}
81+
82+
async function boot(engine: unknown) {
83+
const plugin = new PlatformObjectsPlugin();
84+
const ctx = makeCtx();
85+
ctx.registerService('objectql', engine);
86+
await plugin.init(ctx);
87+
await plugin.start(ctx);
88+
await ctx._flushReady();
89+
}
90+
91+
it('attests a store this boot created from empty', async () => {
92+
const engine = engineWith(true);
93+
94+
await boot(engine);
95+
96+
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
97+
'adr-0104-file-references',
98+
'adr-0104-value-shapes',
99+
]);
100+
for (const row of engine.rows) {
101+
expect(row.verified_at).toBeTruthy();
102+
expect(row.blocking).toBe(0);
103+
}
104+
});
105+
106+
/**
107+
* The property the whole design rests on: a store that was FOUND — an
108+
* upgrade, a restart, anything with history — attests nothing and keeps
109+
* producing its evidence by scan.
110+
*/
111+
it('attests nothing on a store that already existed', async () => {
112+
const engine = engineWith(false);
113+
114+
await boot(engine);
115+
116+
expect(engine.rows).toHaveLength(0);
117+
});
118+
119+
it('attests nothing when the engine cannot say (older engine)', async () => {
120+
const engine = engineWith(undefined);
121+
122+
await boot(engine);
123+
124+
expect(engine.rows).toHaveLength(0);
125+
});
126+
127+
it('a failing attestation never breaks the boot', async () => {
128+
const engine = engineWith(true);
129+
engine.insert = async () => {
130+
throw new Error('disk full');
131+
};
132+
133+
await expect(boot(engine)).resolves.toBeUndefined();
134+
});
135+
136+
it('invalidates the engine memo after attesting (fast-boot race)', async () => {
137+
const engine = engineWith(true);
138+
let invalidated = 0;
139+
engine.invalidateDataMigrationFlags = () => { invalidated++; };
140+
141+
await boot(engine);
142+
143+
expect(invalidated).toBe(1);
144+
});
145+
});

0 commit comments

Comments
 (0)