Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/sys-migration-ledger-platform-infra.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/platform-objects": minor
"@objectstack/service-storage": patch
"@objectstack/cli": patch
---

feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243)

The deployment-level data-migration flag ledger (`sys_migration`, #3617) was
registered by `@objectstack/service-storage` as its first consumer. That was
deliberate while the file migration was the only consumer, but the ledger now
gates storage-independent behaviour too — `os migrate value-shapes` (#4235)
and the fresh-datastore attestation (#4215) — and a non-file migration had to
boot the whole storage plugin just so the kernel carried the table. Any kernel
assembled without storage silently had no ledger at all, which read exactly
like "migration not run" (both answer false) while actually meaning "ledger
not installed".

The registration now lives in `PlatformObjectsPlugin`
(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already
auto-injects into every served kernel — so the ledger exists with the
platform, independent of which optional services are composed. The
fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger
bookkeeping, and its old home justified itself as "the service that registers
`sys_migration`". Definition ownership is unchanged (`sys_migration` stays in
`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the
flag helpers and readers are untouched.

Consequences:

- `@objectstack/service-storage` no longer contributes `sys_migration` to the
manifest and no longer performs the fresh-datastore attestation. An embedder
composing `StorageServicePlugin` on a hand-built kernel that relied on it
for the ledger must compose `PlatformObjectsPlugin` (the plugin every
supported assembly path already includes).
- The CLI's `buildDataMigrationPlugins()` no longer boots storage for every
gated migration — it registers `PlatformObjectsPlugin` always, and settings
+ storage only for `os migrate files-to-references` (`{ storage: true }`),
the one migration that actually reconciles against the storage adapter.
2 changes: 1 addition & 1 deletion packages/cli/src/commands/migrate/files-to-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export default class MigrateFilesToReferences extends Command {
try {
stack = await bootSchemaStack({
databaseUrl: flags['database-url'],
extraPlugins: await buildDataMigrationPlugins(),
extraPlugins: await buildDataMigrationPlugins({ storage: true }),
});
} catch (error: any) {
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1490,12 +1490,15 @@ export default class Serve extends Command {
}
}

// 5c. Auto-register PlatformObjectsPlugin so platform-default
// 5c. Auto-register PlatformObjectsPlugin. It carries platform
// infrastructure every served kernel needs: the `sys_migration`
// data-migration flag ledger + fresh-datastore attestation (#4243 —
// without it the engine's deployment gates read "no ledger" and fall
// back to the lax legacy posture), and the platform-default
// translation bundles (Setup App + metadata-type configuration
// forms shipped by @objectstack/platform-objects) are contributed
// into the kernel's i18n service. Without this, Setup nav labels
// and metadata-admin form labels fall back to English literals
// even when Accept-Language requests another locale.
// forms). Without the latter, Setup nav labels and metadata-admin
// form labels fall back to English literals even when
// Accept-Language requests another locale.
const hasPlatformObjectsPlugin = plugins.some(
(p: any) => p?.name === 'com.objectstack.platform-objects'
|| p?.constructor?.name === 'PlatformObjectsPlugin'
Expand Down
60 changes: 32 additions & 28 deletions packages/cli/src/utils/data-migration-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,42 @@
import { resolveStorageCapabilityArg } from '../commands/serve.js';

/**
* The service plugins a gated data migration boots with, so the kernel carries
* `sys_migration` (the deployment-level flag ledger) — and, for the file
* migration, `sys_file` plus the deployment's REAL storage adapter.
* The plugins a gated data migration boots with.
*
* Settings first: the storage plugin re-resolves its adapter from persisted
* settings when a settings service is present, which is how an S3-configured
* deployment's backfill uploads land in S3 rather than on this machine.
* Storage config goes through the SAME resolver `os serve` uses
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
* the server would. It did not, and that mattered more here than anywhere: the
* file migration reconciles what records claim against what storage actually
* holds, so a root that disagrees with the server's reconciles against the
* wrong tree.
* Every gated migration needs the `sys_migration` flag ledger (#3617), and
* that is all most of them need: it is registered by `PlatformObjectsPlugin`
* — platform infrastructure, present with or without any optional service
* (#4243). `os migrate value-shapes` boots exactly that.
*
* ⚠️ `sys_migration` is registered by the STORAGE plugin (its first consumer),
* which is why a migration with nothing to do with files still boots it. That
* coupling is not this module's to fix — the ledger is platform infrastructure
* and should not be owned by an optional service — but until it moves, every
* gated migration boots the same set so they all find the same ledger. Tracked
* separately; `os serve` auto-wires storage, so a served deployment always has
* it registered and the runtime gates can read their flags.
* Only the FILE migration (`os migrate files-to-references`) also needs
* `sys_file` plus the deployment's REAL storage adapter — it reconciles what
* records claim against what storage actually holds, so a root that disagrees
* with the server's reconciles against the wrong tree. `storage: true` adds:
*
* - Settings first: the storage plugin re-resolves its adapter from
* persisted settings when a settings service is present, which is how an
* S3-configured deployment's backfill uploads land in S3 rather than on
* this machine.
* - Storage config through the SAME resolver `os serve` uses
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly
* where the server would.
*/
export async function buildDataMigrationPlugins(): Promise<unknown[]> {
export async function buildDataMigrationPlugins(
opts: { storage?: boolean } = {},
): Promise<unknown[]> {
const plugins: unknown[] = [];
try {
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
} catch {
// optional — without it, constructor/env-driven storage config still applies
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
plugins.push(new PlatformObjectsPlugin());
if (opts.storage === true) {
try {
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
} catch {
// optional — without it, constructor/env-driven storage config still applies
}
const { StorageServicePlugin } = await import('@objectstack/service-storage');
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
}
const { StorageServicePlugin } = await import('@objectstack/service-storage');
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
return plugins;
}
145 changes: 145 additions & 0 deletions packages/platform-objects/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { PlatformObjectsPlugin } from './plugin.js';
import { SysMigration } from './system/index.js';

/**
* Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this
* package has no kernel dependency and the plugin is structurally typed, so
* booting a real kernel here would buy nothing.
*/
function makeCtx() {
const services = new Map<string, any>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[] } = { info: [], warn: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: any) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => {
it('registers SysMigration through the manifest service', async () => {
const ctx = makeCtx();
const manifests: any[] = [];
ctx.registerService('manifest', { register: (m: any) => manifests.push(m) });

await new PlatformObjectsPlugin().init(ctx);

expect(manifests).toHaveLength(1);
expect(manifests[0].id).toBe('com.objectstack.platform-objects');
expect(manifests[0].scope).toBe('system');
expect(manifests[0].objects).toEqual([SysMigration]);
expect(manifests[0].objects[0].name).toBe('sys_migration');
});

it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => {
const plugin = new PlatformObjectsPlugin();
const ctx = makeCtx();

await expect(plugin.init(ctx)).resolves.toBeUndefined();
await plugin.start(ctx);
await expect(ctx._flushReady()).resolves.toBeUndefined();
});
});

describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', () => {
/** Engine fake with a `sys_migration` table and a settable newness verdict. */
function engineWith(createdFromEmpty: boolean | undefined) {
const rows: Array<Record<string, unknown>> = [];
const engine: any = {
getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined),
find: async (_object: string, options: any) =>
rows.filter((r) => options?.where?.id === undefined || r.id === options.where.id),
insert: async (_object: string, data: any) => {
rows.push({ ...data });
return data;
},
update: async () => ({}),
rows,
};
if (createdFromEmpty !== undefined) {
engine.wasDatastoreCreatedFromEmpty = () => createdFromEmpty;
}
return engine;
}

async function boot(engine: unknown) {
const plugin = new PlatformObjectsPlugin();
const ctx = makeCtx();
ctx.registerService('objectql', engine);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
}

it('attests a store this boot created from empty', async () => {
const engine = engineWith(true);

await boot(engine);

expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
'adr-0104-file-references',
'adr-0104-value-shapes',
]);
for (const row of engine.rows) {
expect(row.verified_at).toBeTruthy();
expect(row.blocking).toBe(0);
}
});

/**
* The property the whole design rests on: a store that was FOUND — an
* upgrade, a restart, anything with history — attests nothing and keeps
* producing its evidence by scan.
*/
it('attests nothing on a store that already existed', async () => {
const engine = engineWith(false);

await boot(engine);

expect(engine.rows).toHaveLength(0);
});

it('attests nothing when the engine cannot say (older engine)', async () => {
const engine = engineWith(undefined);

await boot(engine);

expect(engine.rows).toHaveLength(0);
});

it('a failing attestation never breaks the boot', async () => {
const engine = engineWith(true);
engine.insert = async () => {
throw new Error('disk full');
};

await expect(boot(engine)).resolves.toBeUndefined();
});

it('invalidates the engine memo after attesting (fast-boot race)', async () => {
const engine = engineWith(true);
let invalidated = 0;
engine.invalidateDataMigrationFlags = () => { invalidated++; };

await boot(engine);

expect(invalidated).toBe(1);
});
});
Loading
Loading