From 7419d666518afbcc5b2ab2cef821e454cf218eff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:49:08 +0000 Subject: [PATCH 1/6] wip(cli,metadata-protocol): compose the migration-plans registry in the migrate data boot; owner registers its plan Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../cli/src/utils/data-migration-plugins.ts | 2 + packages/metadata-protocol/src/plugin.ts | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index 58fb2b79ea9..a72f8bf22a9 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -36,6 +36,8 @@ export async function buildDataMigrationPlugins( const plugins: unknown[] = []; const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); plugins.push(new PlatformObjectsPlugin()); + const { MigrationRecoveryPlugin } = await import('@objectstack/runtime'); + plugins.push(new MigrationRecoveryPlugin()); if (opts.audit === true) { // [#21120] `os migrate audit-metadata-bodies` reads and rewrites // `sys_audit_log` / `sys_activity` rows, so their schema must be diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts index 13b9eb9c7e1..122da992e94 100644 --- a/packages/metadata-protocol/src/plugin.ts +++ b/packages/metadata-protocol/src/plugin.ts @@ -22,7 +22,7 @@ * an actionable configuration message. */ -import type { Plugin, PluginContext } from '@objectstack/core'; +import type { MigrationPlanProvider, Plugin, PluginContext } from '@objectstack/core'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { SysMetadataObject, @@ -43,6 +43,7 @@ import { backfillSeedTenancy, resolveSeedTenancySeam, } from './migrations/seed-tenancy-backfill.js'; +import { createRecordedBySentinelPlan } from './migrations/recorded-by-sentinel.js'; import { ObjectStackProtocolImplementation } from './protocol.js'; import type { MetadataAuthoringChannel } from './protocol.js'; @@ -358,6 +359,48 @@ export function assembleMetadataProtocol( }); } + // [#21498] Hand this package's journal-backed migration plans + // (ADR-0119 D2) to the kernel's `migration-plans` registry, so a run + // the journal says was interrupted can be resumed — and described as + // resumable — in any process that loads this package. + // + // A journal holds a plan's HASH, never its callbacks, so recovery + // needs the code handed back by whoever owns it. This package owns + // `metadata.recorded-by-sentinel-to-null` (it rewrites + // `sys_metadata_history`, one of the objects above). Registering it + // here keeps `os migrate resume`'s refusal — "no loaded package + // registers" the plan — true exactly when it is: wherever this + // assembly ran, the owner IS loaded. The registry belongs to + // `MigrationRecoveryPlugin` (`@objectstack/runtime`), which the + // `os migrate` data boot and the `os serve` boot compose. + // + // At `kernel:ready`, not here: that plugin registers the registry in + // its `init()`, which the kernel orders AFTER this assembly's (it + // declares the engine an optional dependency), so asking now would + // read "absent" off a registry still filling. By `kernel:ready` + // every `init()` has run and absence is final — a kernel with no + // registry has nowhere to hand the plan, which is not a fault. The + // plan still lands before the recovery plugin's boot scan: that + // plugin hooks `kernel:ready` from its `start()` (Phase 2), this hook + // is registered in Phase 1, and `dispatchHookPropagating` + // (`@objectstack/core`) runs a hook's handlers in registration + // order — so the scan names the resume command instead of calling + // the run unresumable. + // + // ⛔ Not gated on `runPlatformMigrations`: that gate decides whether a + // boot REPAIRS rows. Registering a plan runs nothing — only an + // operator's `os migrate resume` does — it states that the plan's + // code is loaded here, which is true wherever this assembly ran. + (ctx as any)?.hook?.('kernel:ready', () => { + let plans: MigrationPlanProvider | undefined; + try { + plans = ctx.getService('migration-plans') as MigrationPlanProvider; + } catch { + return; // no registry composed on this kernel + } + plans?.register?.(createRecordedBySentinelPlan()); + }); + // NO `analytics` fallback rides here anymore (#3891 / #3878). The // degraded shim this assembly used to register dropped the request's // ExecutionContext (aggregates ran without RLS/tenant predicates) From 1175f4380d919f7b70a323e4e6f4e380aa175dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:50:40 +0000 Subject: [PATCH 2/6] wip(runtime,cli): bootScan option; serve composes MigrationRecoveryPlugin beside the journal Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 26 +++++++++++++++ .../cli/src/utils/data-migration-plugins.ts | 13 ++++++-- packages/runtime/src/index.ts | 1 + .../runtime/src/migration-recovery-plugin.ts | 33 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 13fefe85aea..1b98c322fb9 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3864,6 +3864,32 @@ export default class Serve extends Command { } } + // 5c-bis. [#21498] Auto-register MigrationRecoveryPlugin beside the + // journal it reads. PlatformObjectsPlugin registers + // `sys_migration_journal` on every served kernel because ADR-0119 D2 + // requires recovery to be discoverable "with zero host wiring"; this is + // the half that discovers. Its `kernel:ready` scan reports every run + // that started and never concluded, naming `os migrate resume --run …`, + // and its `migration-plans` registry is where a plan's owner hands the + // plan over so the scan can say the run IS resumable. On a database with + // no interrupted run the scan prints nothing. + // + // Guarded like the block above: a host config that composes its own + // instance keeps it, and the kernel never holds two registries. + const hasMigrationRecoveryPlugin = plugins.some( + (p: any) => p?.name === 'com.objectstack.migration-recovery' + || p?.constructor?.name === 'MigrationRecoveryPlugin' + ); + if (!hasMigrationRecoveryPlugin) { + try { + const { MigrationRecoveryPlugin } = await import('@objectstack/runtime'); + await kernel.use(new MigrationRecoveryPlugin()); + trackPlugin('MigrationRecovery'); + } catch (err: any) { + console.warn(chalk.yellow(` ⚠ MigrationRecoveryPlugin auto-inject failed: ${err?.message ?? err}`)); + } + } + // 5d. Auto-register AuthPlugin (and paired Security/Audit) when the // 'auth' tier is enabled and no auth plugin is already configured. // The Console expects /api/v1/auth/* to be served by better-auth via diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index a72f8bf22a9..ba0f372b455 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -9,7 +9,9 @@ import { oneShotSettingsPlugin } from './one-shot-settings.js'; * 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. + * (#4243). `os migrate value-shapes` boots exactly that, plus the + * `migration-plans` registry every data boot carries (#21498): a journal-backed + * run started here is resumed by `os migrate resume`, a boot of this same set. * * Only the FILE migration (`os migrate files-to-references`) also needs * `sys_file` plus the deployment's REAL storage adapter — it reconciles what @@ -36,8 +38,15 @@ export async function buildDataMigrationPlugins( const plugins: unknown[] = []; const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); plugins.push(new PlatformObjectsPlugin()); + // [#21498] The `migration-plans` registry (ADR-0119 D2), once per boot. The + // plan's owner hands it over at `kernel:ready` (`@objectstack/metadata-protocol` + // registers `metadata.recorded-by-sentinel-to-null`), and `os migrate resume` + // looks it up after the boot — two processes never share a registry, so the + // resume boot needs its own, filled by the owner, not by the run it resumes. + // `bootScan: false`: this boot is the action surface the scan points at, and + // the plugin's option docblock records what the scan cost here when measured. const { MigrationRecoveryPlugin } = await import('@objectstack/runtime'); - plugins.push(new MigrationRecoveryPlugin()); + plugins.push(new MigrationRecoveryPlugin({ bootScan: false })); if (opts.audit === true) { // [#21120] `os migrate audit-metadata-bodies` reads and rewrites // `sys_audit_log` / `sys_activity` rows, so their schema must be diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 9cdee7f7ce7..32342e99ca4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -56,6 +56,7 @@ export { DriverPlugin } from './driver-plugin.js'; // runs that started and never concluded, and owns the `migration-plans` // registry `os migrate resume` looks plans up in. export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-recovery-plugin.js'; +export type { MigrationRecoveryPluginOptions } from './migration-recovery-plugin.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; diff --git a/packages/runtime/src/migration-recovery-plugin.ts b/packages/runtime/src/migration-recovery-plugin.ts index dcff42a3882..1d08063cdcc 100644 --- a/packages/runtime/src/migration-recovery-plugin.ts +++ b/packages/runtime/src/migration-recovery-plugin.ts @@ -54,7 +54,34 @@ import { MIGRATION_JOURNAL_OBJECT } from '@objectstack/spec/system'; * operators to ignore this plugin's output, which is the one thing it cannot * afford. A scan that FAILS, by contrast, is reported: "I could not check" and * "there is nothing to find" are different answers. + * + * ## Where it is composed (#21498) + * + * Once per kernel that can host a plan — never twice, because the registry is + * the one place a plan's owner and `os migrate resume` meet: + * + * - every `os serve` boot (and so `os start` / `os dev`, which spawn it), + * beside `PlatformObjectsPlugin`, which registers the journal this scans. + * A host config that composes its own instance keeps it; + * - the `os migrate` data boot (`buildDataMigrationPlugins` in the CLI), with + * `bootScan: false` — see {@link MigrationRecoveryPluginOptions.bootScan}. */ +export interface MigrationRecoveryPluginOptions { + /** + * Run the `kernel:ready` journal scan. Default `true`: a serving boot is + * discovery. + * + * `false` composes the `migration-plans` registry alone, which is what a + * one-shot `os migrate` boot takes (#21498). That boot IS the action surface + * the scan points an operator at — `os migrate resume` lists the journal + * itself — and its read-only modes hold schema DDL back, so on a database the + * journal table was never created on the scan can only answer "scan failed". + * Measured: composed with the scan, a dry run on a fresh database gained that + * warning, and `os migrate resume` printed every interrupted run twice. + */ + readonly bootScan?: boolean; +} + export class MigrationRecoveryPlugin implements Plugin { readonly name = 'com.objectstack.migration-recovery'; readonly type = 'standard'; @@ -64,6 +91,11 @@ export class MigrationRecoveryPlugin implements Plugin { readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql']; private readonly registry = new MigrationPlanRegistry(); + private readonly bootScan: boolean; + + constructor(options: MigrationRecoveryPluginOptions = {}) { + this.bootScan = options.bootScan !== false; + } async init(ctx: PluginContext): Promise { // Registered in init so plugins that contribute plans can find the service @@ -72,6 +104,7 @@ export class MigrationRecoveryPlugin implements Plugin { } async start(ctx: PluginContext): Promise { + if (!this.bootScan) return; // registry only — see MigrationRecoveryPluginOptions.bootScan (ctx as any)?.hook?.('kernel:ready', async () => { const logger = (ctx as any)?.logger; let engine: IObjectQLEngine | undefined; From 033922ab3d3414257626a7ce9743f4a4b72c487e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:58:01 +0000 Subject: [PATCH 3/6] wip: pins for the resume path, the serve boot scan and the owner registration Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../resume.recorded-by.integration.test.ts | 352 ++++++++++++++++++ ...gration-recovery-plugin.plan-owner.test.ts | 116 ++++++ .../src/migration-recovery-plugin.test.ts | 25 ++ 3 files changed, 493 insertions(+) create mode 100644 packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts create mode 100644 packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts diff --git a/packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts b/packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts new file mode 100644 index 00000000000..3e384dca3d6 --- /dev/null +++ b/packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts @@ -0,0 +1,352 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21498] An interrupted `os migrate recorded-by` run resumes to completion + * through `os migrate resume --run … --yes`, and an `os serve` boot over that + * run reports it through the journal scan. + * + * ## The measured defect + * + * `MigrationRecoveryPlugin` owns the `migration-plans` registry and the boot + * scan, and nothing composed it. So `recorded-by`'s `plans.register()` landed + * in its no-registry `catch`, `os migrate resume` found no plan for any run and + * refused with "no loaded package registers" it — while the plan's owner, + * `@objectstack/metadata-protocol`, was loaded in that very process — and a + * serve boot ran no scan at all. Measured at the public door on `25797a16e1`: + * the list answered `resumable: false`, `--run … --yes` exited 1 with that + * refusal, and a served boot over the run printed nothing about it. + * + * ## What is pinned + * + * One interrupted run, made the way a crash makes one: a child process runs + * the recorded-by plan under the real journal runner and is SIGKILLed inside + * chunk 0's transaction, leaving `run_started` + `chunk_started(0)` and the + * sentinel rows untouched. Then, each against its own copy of that database: + * + * 1. the REAL command (oclif parse, boot, journal read): the list reports the + * run `resumable: true`, and `--run --yes` completes it — exit 0, + * status `completed`, no row left holding the sentinel, `run_done` + * journalled; + * 2. the REAL `os serve` (spawned, as `os start` / `os dev` spawn it) reports + * the run at boot with the resume command, which means the plan's owner + * registered it before the scan read the registry; + * 3. the control for 2: `os serve` over a fresh database is silent — the scan + * composed into every served boot adds no line when there is nothing to + * report. + * + * ⛔ The interruption is in chunk 0, before any chunk committed. A run that + * had committed a chunk is refused `PLAN_CHANGED` on resume — `recorded-by`'s + * `load()` selects only rows still holding the sentinel, so the chunk plan it + * recomputes no longer hashes to the one the journal recorded. That is a + * defect of the plan's shape, not of this composition, and it is reported on + * its own; this file does not pin around it. + * + * Every boot runs in a hook: a case only reads what a boot printed or wrote. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { spawn, spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { RECORDED_BY_SENTINEL, RECORDED_BY_SENTINEL_PLAN_ID } from '@objectstack/metadata-protocol'; +import MigrateResume from './resume.js'; +import { isExitSignal } from '../../utils/format.js'; + +// Pay the first transform of this dist-resolved workspace dep at MODULE LOAD: +// the command's boot reaches it through a dynamic `import()`. +import '@objectstack/runtime'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +/** The source entry, as `test/helpers/serve-process.ts` spawns it; `src/` cannot import that helper. */ +const CLI = resolve(HERE, '../../../bin/run-dev.js'); + +const HOOK_TIMEOUT_MS = 300_000; +const CHILD_BUDGET_MS = 120_000; + +const ARTIFACT = { + manifest: { id: 'com.example.resume-21498', name: 'Resume 21498', version: '0.0.0', type: 'app' }, + objects: [{ name: 'rz_note', fields: { name: { type: 'text' } } }], +}; + +/** Three history rows holding the sentinel — one chunk at the plan's default size. */ +const SENTINEL_IDS = ['h_21498_a', 'h_21498_b', 'h_21498_c']; + +/** Env that would point a boot somewhere other than the fixture. */ +const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const; + +/** + * This process's environment for a child, minus the families + * `test/helpers/serve-process.ts` `childEnv()` strips (its header says why). + * An `undefined` override unsets a variable. + */ +function childEnv(overrides: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === 'TEST' || key === 'VITEST' || key.startsWith('VITEST_') || key === 'NODE_PATH') continue; + env[key] = value; + } + const unset: Record = {}; + for (const key of OVERRIDING_ENV) unset[key] = undefined; + return { ...env, ...unset, ...overrides }; +} + +/** + * The crash. Boots the data stack over the fixture database, writes the + * sentinel rows, then runs the recorded-by plan under the real runner with a + * forward that kills the process from inside chunk 0's transaction. The plan + * keeps the owner's id, step names and chunk size, so the journal records the + * hash the owner's registered plan computes on resume. + */ +const CRASH_CHILD = ` +const rt = await import('@objectstack/runtime'); +const core = await import('@objectstack/core'); +const mp = await import('@objectstack/metadata-protocol'); +const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); +const stack = await rt.createStandaloneStack({ + projectRoot: process.env.FIXTURE_PROJECT, + databaseUrl: 'file:' + process.env.FIXTURE_DB, + skipSeedData: true, + armLifecycleSweep: false, +}); +const runtime = new rt.Runtime({ cluster: false }); +const kernel = runtime.getKernel(); +for (const plugin of stack.plugins) await kernel.use(plugin); +await kernel.use(new PlatformObjectsPlugin()); +await runtime.start(); +const ql = kernel.getService('objectql'); +const at = new Date().toISOString(); +const ids = JSON.parse(process.env.FIXTURE_IDS); +for (const [i, id] of ids.entries()) { + await ql.insert('sys_metadata_history', { + id, event_seq: i + 1, name: 'rz_' + i, type: 'object', version: 1, + operation_type: 'create', recorded_by: process.env.FIXTURE_SENTINEL, recorded_at: at, + }, { context: { isSystem: true } }); +} +const owned = mp.createRecordedBySentinelPlan(); +const step = owned.steps[0]; +const crashing = { ...owned, steps: [{ ...step, forward: async (_rows, ctx) => { + process.stderr.write('[fixture] run ' + ctx.runId + ' killed in chunk ' + ctx.chunkIndex + '\\n'); + process.kill(process.pid, 'SIGKILL'); + await new Promise(() => {}); +} }] }; +await core.runMigrationJournal(ql, crashing); +process.stderr.write('[fixture] the run finished, so nothing was interrupted\\n'); +process.exit(3); +`; + +interface ProjectCopy { + dir: string; + dbFile: string; +} + +function makeProject(root: string, name: string): ProjectCopy { + const dir = join(root, name); + mkdirSync(join(dir, 'dist'), { recursive: true }); + mkdirSync(join(dir, 'data'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT)); + return { dir, dbFile: join(dir, 'data', 'app.db') }; +} + +/** `os serve` over a project, until its ready banner (or exit); never left running. */ +function serveBoot(project: ProjectCopy): Promise { + return new Promise((resolveRun, rejectRun) => { + const port = String(41000 + Math.floor(Math.random() * 18000)); + const child = spawn(process.execPath, [CLI, 'serve', '--port', port, '--no-ui', '--log-level', 'warn'], { + cwd: project.dir, + env: childEnv({ + NO_COLOR: '1', + OS_ARTIFACT_PATH: join(project.dir, 'dist', 'objectstack.json'), + OS_DATABASE_URL: `file:${project.dbFile}`, + OS_LOG_LEVEL: undefined, + OS_DISABLE_CONSOLE: '1', + // The fixed key `serve-process.ts` hands its children, so no boot mints + // and persists a crypto key into this runner's home directory. + OS_SECRET_KEY: '0e2e'.repeat(16), + }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let settled = false; + const finish = (err?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + if (err) rejectRun(err); + else resolveRun(output); + }; + const timer = setTimeout( + () => finish(new Error(`os serve did not reach its ready banner in time\n${output}`)), + HOOK_TIMEOUT_MS - 30_000, + ); + const onData = (chunk: unknown) => { + output += String(chunk); + if (/Press Ctrl\+C to stop/.test(output)) finish(); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('error', (err) => finish(err)); + child.on('exit', () => finish()); + }); +} + +/** Run `os migrate resume` in-process and capture its one `--json` document. */ +async function resumeJson(argv: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let thrownExit: number | undefined; + try { + try { + await MigrateResume.run([...argv, '--json'], { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + return { payload: JSON.parse(out), exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +/** Rows of the fixture database, read on a connection of our own. */ +async function readRows(dbFile: string, sql: string, bindings: unknown[] = []): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + try { + return await (driver as any).knex.raw(sql, bindings); + } finally { + await driver.disconnect(); + } +} + +let root: string; +let runId: string; +let resumeList: { payload: any; exitCode: number }; +let resumeAct: { payload: any; exitCode: number }; +let resumed: ProjectCopy; +let scanOutput: string; +let freshOutput: string; + +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-21498-')); + const origin = makeProject(root, 'origin'); + + // ── the crash ────────────────────────────────────────────────────────────── + const crash = spawnSync(process.execPath, ['--input-type=module', '-e', CRASH_CHILD], { + cwd: CLI_ROOT, + env: childEnv({ + OS_ARTIFACT_PATH: join(origin.dir, 'dist', 'objectstack.json'), + FIXTURE_PROJECT: origin.dir, + FIXTURE_DB: origin.dbFile, + FIXTURE_IDS: JSON.stringify(SENTINEL_IDS), + FIXTURE_SENTINEL: RECORDED_BY_SENTINEL, + }), + encoding: 'utf8', + timeout: CHILD_BUDGET_MS, + }); + const killed = /\[fixture\] run (\S+) killed in chunk 0/.exec(crash.stderr ?? ''); + if (crash.signal !== 'SIGKILL' || !killed) { + throw new Error( + `the fixture did not leave an interrupted run (status ${crash.status}, signal ${crash.signal})\n${crash.stderr}`, + ); + } + runId = killed[1]; + + // Each consumer gets its own copy: a resume concludes the run, a serve boot + // writes rows of its own. + resumed = makeProject(root, 'resumed'); + const scanned = makeProject(root, 'scanned'); + cpSync(join(origin.dir, 'data'), join(resumed.dir, 'data'), { recursive: true }); + cpSync(join(origin.dir, 'data'), join(scanned.dir, 'data'), { recursive: true }); + const fresh = makeProject(root, 'fresh'); + + // ── 1. the real command ──────────────────────────────────────────────────── + const savedEnv: Record = {}; + const savedCwd = process.cwd(); + for (const key of [...OVERRIDING_ENV, 'OS_ARTIFACT_PATH'] as const) savedEnv[key] = process.env[key]; + try { + for (const key of OVERRIDING_ENV) delete process.env[key]; + process.env.OS_ARTIFACT_PATH = join(resumed.dir, 'dist', 'objectstack.json'); + process.chdir(resumed.dir); + const url = `file:${resumed.dbFile}`; + resumeList = await resumeJson(['--database-url', url]); + resumeAct = await resumeJson(['--run', runId, '--yes', '--database-url', url]); + } finally { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + // ── 2 and 3. the real serve boot ─────────────────────────────────────────── + scanOutput = await serveBoot(scanned); + freshOutput = await serveBoot(fresh); +}, HOOK_TIMEOUT_MS); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('os migrate resume completes an interrupted recorded-by run (#21498)', () => { + it('lists the run as resumable — its plan is registered in the resume boot', () => { + expect(resumeList.exitCode).toBe(0); + const listed = resumeList.payload.interrupted.find((r: any) => r.runId === runId); + expect(listed, JSON.stringify(resumeList.payload)).toBeDefined(); + expect(listed.planId).toBe(RECORDED_BY_SENTINEL_PLAN_ID); + expect(listed.committedChunks).toEqual([]); + expect(listed.unknownChunks).toEqual([0]); + expect(listed.resumable).toBe(true); + }); + + it('--run --yes resumes it to completion and converts every sentinel row', async () => { + expect(resumeAct.exitCode, JSON.stringify(resumeAct.payload)).toBe(0); + expect(resumeAct.payload.runId).toBe(runId); + expect(resumeAct.payload.status).toBe('completed'); + + const rows = await readRows( + resumed.dbFile, + `SELECT id, recorded_by FROM sys_metadata_history WHERE id IN (${SENTINEL_IDS.map(() => '?').join(', ')}) ORDER BY id`, + SENTINEL_IDS, + ); + expect(rows.map((r) => r.id)).toEqual([...SENTINEL_IDS].sort()); + expect(rows.every((r) => r.recorded_by === null), JSON.stringify(rows)).toBe(true); + + const kinds = (await readRows( + resumed.dbFile, + 'SELECT kind FROM sys_migration_journal WHERE run_id = ? ORDER BY seq', + [runId], + )).map((r) => r.kind); + expect(kinds[0]).toBe('run_started'); + expect(kinds.at(-1)).toBe('run_done'); + }); +}); + +describe('os serve reports an interrupted run through the boot scan (#21498)', () => { + it('names the run and the command that resumes it', () => { + const lines = scanOutput.split('\n').filter((l) => l.includes(`'${runId}'`)); + expect(lines, scanOutput).toHaveLength(1); + expect(lines[0]).toContain(`plan '${RECORDED_BY_SENTINEL_PLAN_ID}'`); + // The owner registered the plan before the scan read the registry. + expect(lines[0]).toContain(`Resume with: os migrate resume --run ${runId}`); + }); + + it('is silent on a fresh database (the control)', () => { + expect(freshOutput, freshOutput).toMatch(/Press Ctrl\+C to stop/); + expect(freshOutput).not.toMatch(/interrupted migration/i); + expect(freshOutput).not.toMatch(/journal scan failed/i); + }); +}); diff --git a/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts b/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts new file mode 100644 index 00000000000..98f7f674f5f --- /dev/null +++ b/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21498] On a real kernel, the boot scan reports an interrupted run with its + * plan in hand, because the plan's OWNER registered it first. + * + * `migration-recovery-plugin.test.ts` pins the plugin against a fake context. + * What it cannot see is the composition this card is about: the `migration-plans` + * registry is this plugin's, but the plan in it comes from another package — + * `@objectstack/metadata-protocol` owns `metadata.recorded-by-sentinel-to-null` + * and hands it over from `assembleMetadataProtocol`, the seam every + * `ObjectQLPlugin` kernel runs. Two kernel facts decide whether the scan sees + * it: the registry is registered in this plugin's `init()`, which the kernel + * orders AFTER the engine's, and the owner hands the plan over at + * `kernel:ready`, where this plugin's scan also runs. + * + * So the pin boots the real chain — `ObjectQLPlugin` (and with it the + * protocol assembly), `PlatformObjectsPlugin` (the journal object), this + * plugin — over an in-memory driver holding the journal rows a crash inside + * chunk 0 leaves behind, and reads what the scan said: + * + * - the run is reported, by id, at boot; + * - it is described as RESUMABLE (`Resume with: …`), not as owned by "no + * loaded plugin" — the owner's registration landed before the scan read + * the registry; + * - the registry hands the plan back after boot, which is the lookup + * `os migrate resume` makes. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, type MigrationPlanProvider, type PluginContext } from '@objectstack/core'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { RECORDED_BY_SENTINEL_PLAN_ID } from '@objectstack/metadata-protocol'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { DriverPlugin } from './driver-plugin.js'; +import { MigrationRecoveryPlugin } from './migration-recovery-plugin.js'; + +const RUN_ID = 'run_21498_interrupted'; +const SYSTEM = { context: { isSystem: true } }; + +/** + * Writes the two journal rows a process killed inside chunk 0 leaves behind: + * `run_started` and the autonomous `chunk_started(0)` — no `chunk_done`, no + * `run_failed`. Runs in `start()`, after the engine's, so the rows exist + * before `kernel:ready`; and it captures every warning the kernel logs. + */ +class InterruptedRunFixture { + readonly name = 'test.migration-recovery.interrupted-run'; + readonly version = '1.0.0'; + readonly dependencies = ['com.objectstack.engine.objectql', 'com.objectstack.platform-objects']; + readonly warnings: string[] = []; + + async init(ctx: PluginContext): Promise { + const logger = ctx.logger as { warn: (...args: unknown[]) => unknown }; + const warn = logger.warn.bind(logger); + logger.warn = (...args: unknown[]) => { + this.warnings.push(String(args[0])); + return warn(...args); + }; + } + + async start(ctx: PluginContext): Promise { + const ql = ctx.getService('objectql') as { + insert: (object: string, data: Record, options: unknown) => Promise; + }; + await ql.insert('sys_migration_journal', { + run_id: RUN_ID, + seq: 0, + kind: 'run_started', + plan_hash: 'h', + detail: JSON.stringify({ planId: RECORDED_BY_SENTINEL_PLAN_ID, onCrash: 'resume', chunks: [] }), + created_at: '2026-10-03T00:00:00.000Z', + }, SYSTEM); + await ql.insert('sys_migration_journal', { + run_id: RUN_ID, + seq: 1, + kind: 'chunk_started', + chunk_index: 0, + attempt: 1, + created_at: '2026-10-03T00:00:01.000Z', + }, SYSTEM); + } +} + +describe('MigrationRecoveryPlugin on a real kernel — the owner registers before the scan (#21498)', () => { + let kernel: ObjectKernel; + const fixture = new InterruptedRunFixture(); + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new DriverPlugin(new InMemoryDriver())); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new PlatformObjectsPlugin() as never); + await kernel.use(new MigrationRecoveryPlugin()); + await kernel.use(fixture as never); + await kernel.bootstrap(); + }); + + afterAll(async () => { + if (kernel?.getState() === 'running') await kernel.shutdown(); + }); + + it('reports the interrupted run at boot, as resumable', () => { + const report = fixture.warnings.filter((w) => w.includes(`'${RUN_ID}'`)); + expect(report, fixture.warnings.join('\n')).toHaveLength(1); + expect(report[0]).toContain(`plan '${RECORDED_BY_SENTINEL_PLAN_ID}'`); + expect(report[0]).toContain(`Resume with: os migrate resume --run ${RUN_ID}`); + expect(report[0]).not.toContain('No loaded plugin registers'); + }); + + it('hands the plan back after boot — the lookup `os migrate resume` makes', () => { + const plans = kernel.getService('migration-plans') as MigrationPlanProvider; + expect(plans.get(RECORDED_BY_SENTINEL_PLAN_ID)?.id).toBe(RECORDED_BY_SENTINEL_PLAN_ID); + }); +}); diff --git a/packages/runtime/src/migration-recovery-plugin.test.ts b/packages/runtime/src/migration-recovery-plugin.test.ts index a0d2139217f..bfb262ea154 100644 --- a/packages/runtime/src/migration-recovery-plugin.test.ts +++ b/packages/runtime/src/migration-recovery-plugin.test.ts @@ -80,6 +80,31 @@ describe('MigrationRecoveryPlugin — the migration-plans registry', () => { expect(registry.get('nope')).toBeUndefined(); expect(registry.list()).toEqual([plan]); }); + + it('with bootScan: false, registers the registry and never reads the journal (#21498)', async () => { + // The one-shot `os migrate` boot's composition: the registry a plan's owner + // and `os migrate resume` meet, without a scan that boot would only repeat. + const rows = [ + { run_id: 'r1', seq: 0, kind: 'run_started' }, + { run_id: 'r1', seq: 1, kind: 'chunk_started', chunk_index: 0 }, + ]; + const scanned = { ...engineWith(rows), find: vi.fn(engineWith(rows).find) }; + const ctx = makeCtx({ engine: scanned }); + const plugin = new MigrationRecoveryPlugin({ bootScan: false }); + await plugin.init(ctx); + await plugin.start(ctx); + await ctx._ready(); + expect(ctx._services.get('migration-plans')).toBeDefined(); + expect(scanned.find).not.toHaveBeenCalled(); + expect(ctx._warns).toEqual([]); + + // Control: the default composition over the same journal does scan and report. + const control = { ...engineWith(rows), find: vi.fn(engineWith(rows).find) }; + const controlCtx = makeCtx({ engine: control }); + await boot(controlCtx); + expect(control.find).toHaveBeenCalled(); + expect(controlCtx._warns.join('\n')).toContain("run 'r1'"); + }); }); describe('MigrationRecoveryPlugin — boot scan', () => { From ae1c8c0b0eed2a4fa0a11df341834e26af238ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:01:57 +0000 Subject: [PATCH 4/6] wip: compose MigrationRecoveryPlugin plainly in the migrate data boot (no new runtime option); checklist item follows the composition Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- docs/qa/platform-checklist/FOLLOW-UPS.md | 2 +- .../areas/platform-core.json | 23 +++++++------ .../cli/src/utils/data-migration-plugins.ts | 24 +++++++++----- packages/runtime/src/index.ts | 1 - .../src/migration-recovery-plugin.test.ts | 25 -------------- .../runtime/src/migration-recovery-plugin.ts | 33 ------------------- 6 files changed, 29 insertions(+), 79 deletions(-) diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index aa511332993..0f3f9d83d12 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -277,7 +277,7 @@ declares exactly `.` and `./logger`. Do not re-derive the subpath. | D11 | `POST /api/v1/auth/two-factor/get-totp-uri` live re-reveal vs the reveal dialog's "shown only once" promise; endpoint absent from SDK ledger rows and targeted by no action | `auth-route-ledger.ts` vs `sys-two-factor.object.ts` | identity-auth.two-factor-enrollment-reveal (probe) | auth — maintainer call | | D12 | `os doctor` false-PASS: `findMissingTests`/`findDeprecatedUsages` scan only `/packages/spec/src`, so in any user app doctor prints "✓ Test coverage"/"✓ Deprecations" about a tree it never examined | `doctor.ts,1159-1161` → `✓` at `` | cli.doctor-health-report (expected-fail probe) | correctness — safe to file | | D13 | `FileConstraintError` declares `code: 'ERR_FILE_CONSTRAINT'` but no `status`, and rest's `classifyDataError` has no branch for it — the server-side accept/maxSize refusal exits `/api/v1/data` as a sanitized **500 INTERNAL_ERROR** with the field-naming prose withheld from the body (the sibling `FileFieldBulkWriteError` docblock names `status: 400` as exactly what prevents this; same class as #7525) | `file-reference-lifecycle.ts,181-191`; `packages/rest` error-response classification | attachments-storage.field-accept-maxsize-server-enforced (wire-status recorded per run; a measured 500 is extracted as a finding, not scored as an enforcement fail) | correctness/wire-contract — safe to file | -| D14 | `MigrationRecoveryPlugin` is composed by NO boot path — `serve.ts` auto-registers `PlatformObjectsPlugin` but never the recovery plugin; standalone-stack, default-host, the showcase config, and the migrate CLI boot all omit it; only its unit test instantiates it. Interrupted-migration detection therefore never runs on any shipped boot, while `sys-migration-journal.object.ts` argues recovery must need "zero host wiring" | `packages/runtime/src/index.ts` (exported); `serve.ts` (what IS auto-registered) | platform-core.interrupted-migration-boot-report (fixtures an explicit registration; knownGap names the composition hole) | correctness/composition — safe to file | +| D14 | `MigrationRecoveryPlugin` is composed by NO boot path — `serve.ts` auto-registers `PlatformObjectsPlugin` but never the recovery plugin; standalone-stack, default-host, the showcase config, and the migrate CLI boot all omit it; only its unit test instantiates it. Interrupted-migration detection therefore never runs on any shipped boot, while `sys-migration-journal.object.ts` argues recovery must need "zero host wiring" | `packages/runtime/src/index.ts` (exported); `serve.ts` (what IS auto-registered) | platform-core.interrupted-migration-boot-report (fixtures an explicit registration; knownGap names the composition hole — both dropped at rev 2) | **FIXED by #21498** — every `os serve` boot composes it beside `PlatformObjectsPlugin`, and the `os migrate` data boot composes its registry | | D15 | `extract-hook-body.ts`'s header promises "the build fails… no silent fallback" on a forbidden pattern, but the DEFAULT `os build` catches every extraction error and silently falls back to the.mjs bundle (`lower-callables.ts`), printing the warnings nowhere; only `--strict-body` (`compile.ts`) produces the worded refusals with exit 1. `hook-bodies.mdx` documents the warn-and-bundle default, so code comment and docs disagree with each other | `extract-hook-body.ts` vs `lower-callables.ts`, `compile.ts` | cli.hook-body-extraction-gates (default-path silent-fallback encoded as expected-fail contradiction clause) | correctness — safe to file | ⚠️ **Handle collision on D11 (#21060).** The D11 row above is the two-factor `get-totp-uri` diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 807b86e7e86..a543b474425 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1224,23 +1224,20 @@ "title": "Boot-time migration-journal scan: an interrupted run (started ∧ ¬done) is reported at kernel:ready with the exact `os migrate resume` prescription, boot PROCEEDS without resuming, and the two quiet-degradation arms (no journal object → silent; scan THROWS → 'could not check' warning) stay distinguishable", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "mixed", "personas": ["operator (local shell)"], "fixtures": { "app": "showcase", "requires": [ - "a scratch copy of the app whose objectstack.config.ts plugins array adds `new MigrationRecoveryPlugin()` (exported from @objectstack/runtime — packages/runtime/src/index.ts); PlatformObjectsPlugin (the journal object) is auto-registered by serve.ts §5c, but the RECOVERY plugin is not, so a stock config never runs the scan", + "the app as shipped: every `os serve` boot (and so `os start` / `os dev`, which spawn it) composes MigrationRecoveryPlugin beside PlatformObjectsPlugin (the journal object) — serve.ts §5c and §5c-bis — so a stock config runs the scan; a config whose plugins array composes its own `new MigrationRecoveryPlugin()` keeps that instance instead", "an isolated persistent sqlite DB (own port, -d file:/tmp//journal.db) whose base schema was synced by one prior boot, so sys_migration_journal exists as a table to seed — the same scratch-DB class of fixture cli.migrate-plan-apply-json uses", "a way to hand-insert rows into that sqlite file between boots (sqlite3 CLI or a small node script); the journal's API surface is read-only (apiMethods get/list), so REST cannot seed it" - ], - "knownGaps": [ - "NO stock boot path composes MigrationRecoveryPlugin: it is exported from @objectstack/runtime and instantiated only by its own unit test — serve.ts auto-registers PlatformObjectsPlugin but never this plugin, standalone-stack/default-host do not, the showcase config's plugins array does not, and buildDataMigrationPlugins (the migrate CLI's boot) does not either. A stock showcase boot therefore runs NO journal scan, and its silence over a seeded interrupted row proves nothing. If the runner judges the scan should be on by default (the plugin header and sys-migration-journal.object.ts argue recovery must be 'discoverable with zero host wiring'), that is a composition finding to file — never a reason to tick or to skip the item." ] }, "steps": [ - "create the scratch app per fixtures (config composes `new MigrationRecoveryPlugin()`); boot it once on its own port with -d file:/tmp//journal.db so schema sync creates the sys_migration_journal table; grep this first boot's log for migration-recovery output (expect none — clean-journal baseline); stop the server", + "boot the app once on its own port with -d file:/tmp//journal.db so schema sync creates the sys_migration_journal table; grep this first boot's log for migration-recovery output (expect none — clean-journal baseline); stop the server", "hand-seed the crash-shaped state directly into the sqlite file: one row (run_id 'qa-interrupted-1', seq 0, kind 'run_started', plan_hash 'qa', detail '{\"planId\":\"qa_plan\"}') and one row (run_id 'qa-interrupted-1', seq 1, kind 'chunk_started', chunk_index 0) — started ∧ ¬done with an UNKNOWN-outcome chunk; read the table's real columns first (PRAGMA table_info) and respect the unique (run_id, seq) index; record the exact row count", "boot again against the SAME DB; capture the FULL boot log, then GET /api/v1/health and /api/v1/ready once up", "re-count sys_migration_journal rows after the boot and diff against the pre-boot count", @@ -1250,7 +1247,7 @@ ], "acceptance": [ { - "clause": "the boot log warns per interrupted run, naming the run ('qa-interrupted-1'), the plan ('qa_plan'), the UNKNOWN-outcome chunk ('1 chunk(s) with UNKNOWN outcome (0) — started, never confirmed committed'), and the exact acting command — plus the summary 'interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically — run \\'os migrate resume\\' to act on them.' Because the scratch boot registers no plan, the per-run line is the UNRESUMABLE variant ('No loaded plugin registers plan \\'qa_plan\\' … Load the package that owns it, then: os migrate resume --run qa-interrupted-1', describeInterruptedRun) — that variant still names the command; expecting the bare 'Resume with:' line here is a mis-read of the fixture, not a product FAIL", + "clause": "the boot log warns per interrupted run, naming the run ('qa-interrupted-1'), the plan ('qa_plan'), the UNKNOWN-outcome chunk ('1 chunk(s) with UNKNOWN outcome (0) — started, never confirmed committed'), and the exact acting command — plus the summary 'interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically — run \\'os migrate resume\\' to act on them.' Because no loaded package registers the seeded plan id 'qa_plan', the per-run line is the UNRESUMABLE variant ('No loaded plugin registers plan \\'qa_plan\\' … Load the package that owns it, then: os migrate resume --run qa-interrupted-1', describeInterruptedRun) — that variant still names the command; expecting the bare 'Resume with:' line here is a mis-read of the fixture, not a product FAIL", "oracle": "log", "verify": "grep the second boot's log for the per-run warning and the NOT-resumed summary (emitted at migration-recovery-plugin.ts)", "evidence": "the boot-log excerpt containing both lines" @@ -1262,7 +1259,7 @@ "evidence": "both probe statuses + the two row counts" }, { - "clause": "on a composition where the scan is PROVEN active (clause 1, same config, same DB), a journal with nothing to find boots in silence: the first boot (empty journal) and the third boot (run concluded by run_done) both emit zero migration-recovery lines — findInterruptedRuns treats run_done as concluded (migration-journal.ts) and the empty result returns before any warn. Absence of a warning is only meaningful once the arm is proven — this clause may not be scored on a stock boot, where silence means the plugin was never composed", + "clause": "on a composition where the scan is PROVEN active (clause 1, same config, same DB), a journal with nothing to find boots in silence: the first boot (empty journal) and the third boot (run concluded by run_done) both emit zero migration-recovery lines — findInterruptedRuns treats run_done as concluded (migration-journal.ts) and the empty result returns before any warn. Absence of a warning is only meaningful once the arm is proven — this clause may not be scored before clause 1 proved the scan active on the same composition and DB", "oracle": "log", "verify": "grep boots 1 and 3 for 'migration' / 'interrupted' recovery output; both empty, while boot 2 (between them, same composition) warned", "evidence": "the three grep results side by side" @@ -1283,7 +1280,7 @@ "negative": [ "any journal write caused by booting — a new chunk_started/chunk_done row, the seeded run progressing or concluding — is an auto-resume, THE fail the boot-discovers/CLI-acts split exists for", "a warning emitted on a journal-less lean kernel, or silence on a throwing scan, conflates the two degradation arms — 'silent by design' and 'could not check' must never trade places", - "silence on a STOCK showcase boot over a seeded interrupted row is not a pass of anything: stock composition never runs the scan (see knownGaps) — scoring that silence as 'clean journal' is the absence-inference trap applied to a plugin that was never loaded" + "silence over a seeded interrupted row on a boot whose scan was never proven active is not a pass of anything — score the clean-journal clause only after clause 1 proved the arm; scoring unproven silence as 'clean journal' is the absence-inference trap" ], "traps": ["absence-inference", "stale-dist"], "automated": { "kind": "unit", "ref": "packages/runtime/src/migration-recovery-plugin.test.ts" }, @@ -1291,7 +1288,7 @@ "packages/runtime/src/migration-recovery-plugin.ts#describeInterruptedRun — kernel:ready hook; silent no-engine return; silent absent-journal return; scan-failure warning (string); per-run warns + NOT-resumed summary; describeInterruptedRun (unresumable variant); the boot-discovers/CLI-acts ruling in the header", "packages/core/src/utils/migration-journal.ts#findInterruptedRuns — findInterruptedRuns (started ∧ ¬done; run_done concludes; a run_failed run fully compensated is settled)", "packages/platform-objects/src/system/sys-migration-journal.object.ts#run_id (row contract, (run_id, seq) unique index, apiMethods get/list — registered by PlatformObjectsPlugin)", - "packages/cli/src/commands/serve.ts#kernel (PlatformObjectsPlugin auto-registered into every served kernel — which is why the absent-journal arm cannot be staged on a served boot; MigrationRecoveryPlugin itself is auto-registered NOWHERE)", + "packages/cli/src/commands/serve.ts#kernel (PlatformObjectsPlugin auto-registered into every served kernel — which is why the absent-journal arm cannot be staged on a served boot — and MigrationRecoveryPlugin beside it, §5c-bis, guarded so a config's own instance is kept)", "packages/runtime/CHANGELOG.md 17.0.0, commit 071d0dc — 'boot reconciliation and os migrate resume for the migration journal' (ADR-0119 D2, #4617 deliverable 3)", "cli.migrate-plan-apply-json (the CLI side of the same journal — this item is the boot-side DETECTION that item's `os migrate resume --json` step presumes)" ], @@ -1301,6 +1298,12 @@ "date": "2026-08-20", "change": "new — authored in the scoped scan-functionality coverage sweep: the boot-side journal scan (ADR-0119 D2 deliverable 3, landed v17) had no item; existing coverage touched only the CLI half (cli.migrate-plan-apply-json's resume --json exit-code step). Grounding found the load-bearing fixture fact the item now encodes: MigrationRecoveryPlugin is exported from @objectstack/runtime but composed by NO stock boot path (not serve.ts, not standalone-stack, not the showcase config, not buildDataMigrationPlugins) — only its unit test instantiates it — so the item boots a scratch config that composes it explicitly, and treats stock-boot silence as the absence-inference trap, not evidence", "ref": "claude/new-session-0pv25p" + }, + { + "revision": 2, + "date": "2026-10-03", + "change": "the composition gap the fixtures encoded is closed: every `os serve` boot now composes MigrationRecoveryPlugin beside PlatformObjectsPlugin, and the `os migrate` data boot composes its registry. The scratch-config fixture, the knownGaps entry and the stock-boot negative are rewritten to match; steps, oracles and clauses are otherwise unchanged", + "ref": "#21498" } ] }, diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index ba0f372b455..bab15d8279d 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -10,8 +10,8 @@ import { oneShotSettingsPlugin } from './one-shot-settings.js'; * 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, plus the - * `migration-plans` registry every data boot carries (#21498): a journal-backed - * run started here is resumed by `os migrate resume`, a boot of this same set. + * `MigrationRecoveryPlugin` every data boot carries (#21498). A journal-backed + * run started here is resumed by `os migrate resume`, which boots this same set. * * Only the FILE migration (`os migrate files-to-references`) also needs * `sys_file` plus the deployment's REAL storage adapter — it reconciles what @@ -39,14 +39,20 @@ export async function buildDataMigrationPlugins( const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); plugins.push(new PlatformObjectsPlugin()); // [#21498] The `migration-plans` registry (ADR-0119 D2), once per boot. The - // plan's owner hands it over at `kernel:ready` (`@objectstack/metadata-protocol` - // registers `metadata.recorded-by-sentinel-to-null`), and `os migrate resume` - // looks it up after the boot — two processes never share a registry, so the - // resume boot needs its own, filled by the owner, not by the run it resumes. - // `bootScan: false`: this boot is the action surface the scan points at, and - // the plugin's option docblock records what the scan cost here when measured. + // plan's owner hands its plan over at `kernel:ready` + // (`@objectstack/metadata-protocol` registers + // `metadata.recorded-by-sentinel-to-null`), and `os migrate resume` looks it + // up once the boot is done. Two processes never share a registry, so the + // resume boot needs its own, filled by the plan's owner rather than by the + // run being resumed. + // + // The plugin's journal scan rides along. Its measured effects here: a data + // command booted over an interrupted run warns about that run on stderr + // before it does anything else, and a read-only boot of a database that has no + // journal table yet warns that it could not check. That database is one these + // commands already refuse, because the tables they read are not there either. const { MigrationRecoveryPlugin } = await import('@objectstack/runtime'); - plugins.push(new MigrationRecoveryPlugin({ bootScan: false })); + plugins.push(new MigrationRecoveryPlugin()); if (opts.audit === true) { // [#21120] `os migrate audit-metadata-bodies` reads and rewrites // `sys_audit_log` / `sys_activity` rows, so their schema must be diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 32342e99ca4..9cdee7f7ce7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -56,7 +56,6 @@ export { DriverPlugin } from './driver-plugin.js'; // runs that started and never concluded, and owns the `migration-plans` // registry `os migrate resume` looks plans up in. export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-recovery-plugin.js'; -export type { MigrationRecoveryPluginOptions } from './migration-recovery-plugin.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; diff --git a/packages/runtime/src/migration-recovery-plugin.test.ts b/packages/runtime/src/migration-recovery-plugin.test.ts index bfb262ea154..a0d2139217f 100644 --- a/packages/runtime/src/migration-recovery-plugin.test.ts +++ b/packages/runtime/src/migration-recovery-plugin.test.ts @@ -80,31 +80,6 @@ describe('MigrationRecoveryPlugin — the migration-plans registry', () => { expect(registry.get('nope')).toBeUndefined(); expect(registry.list()).toEqual([plan]); }); - - it('with bootScan: false, registers the registry and never reads the journal (#21498)', async () => { - // The one-shot `os migrate` boot's composition: the registry a plan's owner - // and `os migrate resume` meet, without a scan that boot would only repeat. - const rows = [ - { run_id: 'r1', seq: 0, kind: 'run_started' }, - { run_id: 'r1', seq: 1, kind: 'chunk_started', chunk_index: 0 }, - ]; - const scanned = { ...engineWith(rows), find: vi.fn(engineWith(rows).find) }; - const ctx = makeCtx({ engine: scanned }); - const plugin = new MigrationRecoveryPlugin({ bootScan: false }); - await plugin.init(ctx); - await plugin.start(ctx); - await ctx._ready(); - expect(ctx._services.get('migration-plans')).toBeDefined(); - expect(scanned.find).not.toHaveBeenCalled(); - expect(ctx._warns).toEqual([]); - - // Control: the default composition over the same journal does scan and report. - const control = { ...engineWith(rows), find: vi.fn(engineWith(rows).find) }; - const controlCtx = makeCtx({ engine: control }); - await boot(controlCtx); - expect(control.find).toHaveBeenCalled(); - expect(controlCtx._warns.join('\n')).toContain("run 'r1'"); - }); }); describe('MigrationRecoveryPlugin — boot scan', () => { diff --git a/packages/runtime/src/migration-recovery-plugin.ts b/packages/runtime/src/migration-recovery-plugin.ts index 1d08063cdcc..dcff42a3882 100644 --- a/packages/runtime/src/migration-recovery-plugin.ts +++ b/packages/runtime/src/migration-recovery-plugin.ts @@ -54,34 +54,7 @@ import { MIGRATION_JOURNAL_OBJECT } from '@objectstack/spec/system'; * operators to ignore this plugin's output, which is the one thing it cannot * afford. A scan that FAILS, by contrast, is reported: "I could not check" and * "there is nothing to find" are different answers. - * - * ## Where it is composed (#21498) - * - * Once per kernel that can host a plan — never twice, because the registry is - * the one place a plan's owner and `os migrate resume` meet: - * - * - every `os serve` boot (and so `os start` / `os dev`, which spawn it), - * beside `PlatformObjectsPlugin`, which registers the journal this scans. - * A host config that composes its own instance keeps it; - * - the `os migrate` data boot (`buildDataMigrationPlugins` in the CLI), with - * `bootScan: false` — see {@link MigrationRecoveryPluginOptions.bootScan}. */ -export interface MigrationRecoveryPluginOptions { - /** - * Run the `kernel:ready` journal scan. Default `true`: a serving boot is - * discovery. - * - * `false` composes the `migration-plans` registry alone, which is what a - * one-shot `os migrate` boot takes (#21498). That boot IS the action surface - * the scan points an operator at — `os migrate resume` lists the journal - * itself — and its read-only modes hold schema DDL back, so on a database the - * journal table was never created on the scan can only answer "scan failed". - * Measured: composed with the scan, a dry run on a fresh database gained that - * warning, and `os migrate resume` printed every interrupted run twice. - */ - readonly bootScan?: boolean; -} - export class MigrationRecoveryPlugin implements Plugin { readonly name = 'com.objectstack.migration-recovery'; readonly type = 'standard'; @@ -91,11 +64,6 @@ export class MigrationRecoveryPlugin implements Plugin { readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql']; private readonly registry = new MigrationPlanRegistry(); - private readonly bootScan: boolean; - - constructor(options: MigrationRecoveryPluginOptions = {}) { - this.bootScan = options.bootScan !== false; - } async init(ctx: PluginContext): Promise { // Registered in init so plugins that contribute plans can find the service @@ -104,7 +72,6 @@ export class MigrationRecoveryPlugin implements Plugin { } async start(ctx: PluginContext): Promise { - if (!this.bootScan) return; // registry only — see MigrationRecoveryPluginOptions.bootScan (ctx as any)?.hook?.('kernel:ready', async () => { const logger = (ctx as any)?.logger; let engine: IObjectQLEngine | undefined; From fe23beb97e176fe946160a74c3abb39ed27d89de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:05:43 +0000 Subject: [PATCH 5/6] chore(changeset): cli and metadata-protocol patch for the migration-plans composition Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .changeset/21498-cli-compose-migration-recovery.md | 13 +++++++++++++ ...-metadata-protocol-registers-recorded-by-plan.md | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 .changeset/21498-cli-compose-migration-recovery.md create mode 100644 .changeset/21498-metadata-protocol-registers-recorded-by-plan.md diff --git a/.changeset/21498-cli-compose-migration-recovery.md b/.changeset/21498-cli-compose-migration-recovery.md new file mode 100644 index 00000000000..e2a75e94493 --- /dev/null +++ b/.changeset/21498-cli-compose-migration-recovery.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cli": patch +--- + +`os migrate resume --run --yes` can resume an interrupted `os migrate recorded-by` run, and `os serve` reports interrupted migration runs at boot (#21498) + +Clause-②: no + +`MigrationRecoveryPlugin` owns two things: the `migration-plans` registry, where a journal-backed migration's code is looked up, and the boot scan that reports runs which started and never finished. No CLI boot composed it. So `os migrate resume` found no plan for any run. It refused with "no loaded package registers" the plan, even though the plan's package was loaded in that process. And no `os serve`, `os start` or `os dev` boot ever scanned the migration journal. + +- **The `os migrate` data commands** (`recorded-by`, `resume`, `value-shapes`, `summary-nulls`, `files-to-references`, `meta --stored`, `audit-metadata-bodies`, `os storage orphans`) now boot with the plugin. A run interrupted before any of its chunks committed now resumes to completion. A command booted over an interrupted run also warns about that run on stderr first. +- **Every `os serve` boot** (and so `os start` and `os dev`, which spawn it) composes the plugin beside `PlatformObjectsPlugin`, which registers the journal the scan reads. An interrupted run is reported once at boot, with the `os migrate resume --run ` command that resumes it. Nothing is resumed automatically. A database with no interrupted run prints nothing. A config that composes its own `new MigrationRecoveryPlugin()` keeps that instance. +- **Still refused:** a `recorded-by` run that had committed a chunk before it was interrupted, or that was started with a non-default `--chunk-size`. `resume` now reaches the runner for these runs, and the runner refuses them with `PLAN_CHANGED`. Re-running `os migrate recorded-by --apply` converts whatever rows still hold the sentinel. diff --git a/.changeset/21498-metadata-protocol-registers-recorded-by-plan.md b/.changeset/21498-metadata-protocol-registers-recorded-by-plan.md new file mode 100644 index 00000000000..5c399bcf8ca --- /dev/null +++ b/.changeset/21498-metadata-protocol-registers-recorded-by-plan.md @@ -0,0 +1,11 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +The metadata protocol registers its journal-backed migration plan, `metadata.recorded-by-sentinel-to-null`, with the kernel's `migration-plans` registry (#21498) + +Clause-②: no + +A migration journal records a run's plan hash, not the plan's code. To resume a run, the package that owns the plan has to register it. This package owns the `recorded_by` sentinel-to-NULL plan, and until now it never registered it. So any process that composed the registry still reported the run as unresumable. + +The protocol assembly (`assembleMetadataProtocol`, which `ObjectQLPlugin` and `MetadataProtocolPlugin` both run) now registers the plan at `kernel:ready`. It does so only when a `migration-plans` service is composed. That runs before `MigrationRecoveryPlugin`'s boot scan, so the scan reports the run as resumable. A kernel with no registry is unchanged. Registering a plan runs nothing: only `os migrate resume` acts on it. From e5cdb086b0ff9b90965d408e67c9e69d434e8b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:29:58 +0000 Subject: [PATCH 6/6] test(runtime): the plan-owner pin boots over in-memory SQLite, not the frozen memory driver Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../src/migration-recovery-plugin.plan-owner.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts b/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts index 98f7f674f5f..758e9eba894 100644 --- a/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts +++ b/packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts @@ -16,8 +16,8 @@ * * So the pin boots the real chain — `ObjectQLPlugin` (and with it the * protocol assembly), `PlatformObjectsPlugin` (the journal object), this - * plugin — over an in-memory driver holding the journal rows a crash inside - * chunk 0 leaves behind, and reads what the scan said: + * plugin — over an in-memory SQLite database holding the journal rows a crash + * inside chunk 0 leaves behind, and reads what the scan said: * * - the run is reported, by id, at boot; * - it is described as RESUMABLE (`Resume with: …`), not as owned by "no @@ -29,7 +29,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectKernel, type MigrationPlanProvider, type PluginContext } from '@objectstack/core'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { RECORDED_BY_SENTINEL_PLAN_ID } from '@objectstack/metadata-protocol'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; @@ -89,7 +89,11 @@ describe('MigrationRecoveryPlugin on a real kernel — the owner registers befor beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); - await kernel.use(new DriverPlugin(new InMemoryDriver())); + await kernel.use(new DriverPlugin(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }))); await kernel.use(new ObjectQLPlugin()); await kernel.use(new PlatformObjectsPlugin() as never); await kernel.use(new MigrationRecoveryPlugin());