From 9cad3eaef7e0953426ab2fe43fa3af06605ae5c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:45:05 +0000 Subject: [PATCH 1/6] wip(cli): os migrate plan/apply compose what os serve mounts around the stack The auth family behind serve's auth gate, the provider of every capability serve's resolver mounts (requires + the always-on slate) and the REST API plugin, each for its declarations only; host plugins entries by serve's entry rule; the pinyin-search stamp from the loaded config. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/cli/src/commands/migrate/apply.ts | 4 +- packages/cli/src/commands/migrate/plan.ts | 5 + packages/cli/src/utils/schema-migrate.ts | 22 +- .../cli/src/utils/schema-migration-plugins.ts | 361 +++++++++++++++++- 4 files changed, 384 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 32fdcda10ab..58ef270adf1 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -174,12 +174,14 @@ export default class MigrateApply extends Command { // `composeHostStack` (#12938): reconcile the object set this deployment // actually serves. It must be the SAME set `os migrate plan` diffed — // the plan the operator just read is the thing being confirmed — so the - // two commands pass it identically. + // two commands pass it identically — `composeServedPlatform` (#22506) + // included. stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true, composeHostStack: true, + composeServedPlatform: true, }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index 511c8e81710..c355ffc8f51 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -152,6 +152,11 @@ export default class MigratePlan extends Command { // own boot detector reported ten findings on the same database, and the // command those findings name is this one. composeHostStack: true, + // [#22506] …and what `os serve` mounts AROUND the stack — the auth + // family behind its auth gate, the provider of every capability it + // resolves — or an app declaring `requires: ['auth']` plans without + // `sys_account`, and its retired `issuer` column is never a drop. + composeServedPlatform: true, }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index d18361861d5..6dbc18f2056 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -12,8 +12,11 @@ * outside that set are never examined or altered. The two SCHEMA commands * (`plan`/`apply`) therefore pass `composeHostStack` so the set is the one the * deployment's own `os serve` boot registers — its `objectstack.config.ts` plus - * the platform floor `serve` composes unconditionally (#12938). The DATA - * subcommands keep their own narrower set (`./data-migration-plugins.ts`). + * the platform floor `serve` composes unconditionally (#12938) — and + * `composeServedPlatform`, so it includes what `serve` mounts around the stack: + * the auth family behind its auth gate and the provider of every capability it + * resolves (#22506). The DATA subcommands keep their own narrower set + * (`./data-migration-plugins.ts`). * A project with neither a config nor a compiled artifact still diffs the data * stack alone — run `os build` first so its objects are visible. */ @@ -464,6 +467,16 @@ export async function bootSchemaStack( * every one-shot command boots it — no `dev` key, so no dev schema self-heal. */ serveFlags?: { readonly dev?: boolean; readonly preset?: string }; + /** + * [#22506] With {@link composeHostStack}, also compose what `os serve` + * mounts AROUND the stack, each piece for its declarations only: the auth + * family behind its auth gate, the provider of every capability its + * resolver mounts (the stack's `requires` and the always-on slate), and + * the REST API plugin. Set by `os migrate plan` and `os migrate apply`, and + * by nothing else — their subject is the deployment's whole object set. + * See `buildSchemaMigrationPlugins`'s `servedPlatform`. + */ + composeServedPlatform?: boolean; }, ): Promise { // Taken BEFORE the first line the boot can print. `createStandaloneStack` @@ -551,6 +564,11 @@ export async function bootSchemaStack( ? { authGatedSecurity: { artifactRequires: stack.requires } } : {}), ...(opts.serveFlags ? { serveFlags: opts.serveFlags } : {}), + // [#22506] The compiled artifact's `requires`, as `serve`'s merge lays + // them over the config's, for the auth gate and the provider tokens. + ...(opts.composeServedPlatform === true + ? { servedPlatform: { artifactRequires: stack.requires } } + : {}), }) : { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 543e562ac6a..09729b53f95 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -9,6 +9,7 @@ import { resolveStackTiers, type PlatformAuthSkipReason, } from '@objectstack/core'; +import { stampSearchPinyinEnabled } from '@objectstack/types'; import { isAppPluginLike } from './graft-runtime-hooks.js'; import { isHostConfig } from './plugin-detection.js'; import { @@ -397,10 +398,15 @@ function declarationContext( ctx: unknown, owner: string, lifecycle: DeclarationBootLifecycle | undefined, + withheld: 'post-declaration' | 'every-hook' = 'post-declaration', ): unknown { if (!ctx || typeof ctx !== 'object') return ctx; const target = ctx as Record; const hook = (name: unknown, ...rest: unknown[]): unknown => { + // [#22506] A platform provider registers no hook at all — see + // {@link composeProviderForDeclarations}. Nothing is recorded: the record + // is about HOST code, and this is the platform's own. + if (withheld === 'every-hook') return undefined; if ((POST_DECLARATION_PHASES as readonly unknown[]).includes(name)) { lifecycle?.recordWithheldHook(owner, String(name)); return undefined; @@ -457,6 +463,43 @@ function declarationContext( export function composeForDeclarations( plugin: T, lifecycle?: DeclarationBootLifecycle, +): T { + return declarationProxy(plugin, lifecycle, 'post-declaration'); +} + +/** + * [#22506] A PLATFORM PROVIDER composed for its declarations: `init()` runs, + * `start()` does not, and `init()` registers NO lifecycle hook at all — + * `kernel:ready` included, unlike {@link composeForDeclarations}. + * + * ## Why stricter than a host plugin + * + * {@link composeForDeclarations} keeps `kernel:ready` for host code on + * purpose: a host that provisions its tables from a `kernel:ready` hook is a + * measured shape the plan must see (#13028). A platform provider is not host + * code, and its `kernel:ready` hooks are where its RUNTIME arms — measured on + * #22506, `MessagingServicePlugin.init()` registers the `kernel:ready` hooks + * that start its notification and outbound-HTTP dispatchers. Composed with the + * host posture, both dispatchers ticked inside `os migrate plan`; the write + * guard comes off when the bootstrap ends, so on a database holding pending + * deliveries a dry run could have SENT them. What a schema command needs from a + * provider is the objects its `init()` declares; its loops, schedulers, + * dispatchers and seeding belong to a served boot. + * + * Whether every provider declares its objects in `init()` is not assumed: the + * parity pin (`migrate-plan-boot-parity.integration.test.ts`) compares the + * object set a plan examines with the one a real `os serve` boot registers, per + * example app shape, so a provider that declares from a hook fails one test. + */ +export function composeProviderForDeclarations(plugin: T): T { + return declarationProxy(plugin, undefined, 'every-hook'); +} + +/** The Proxy both declaration postures share — see {@link composeForDeclarations}. */ +function declarationProxy( + plugin: T, + lifecycle: DeclarationBootLifecycle | undefined, + withheld: 'post-declaration' | 'every-hook', ): T { return new Proxy(plugin, { get(target, prop) { @@ -469,7 +512,7 @@ export function composeForDeclarations( return (ctx: unknown, ...rest: unknown[]): unknown => Reflect.apply( value as (...args: unknown[]) => unknown, target, - [declarationContext(ctx, label, lifecycle), ...rest], + [declarationContext(ctx, label, lifecycle, withheld), ...rest], ); } if (typeof value === 'function' && prop !== 'constructor') { @@ -1468,6 +1511,21 @@ export async function buildSchemaMigrationPlugins(opts: { * Unset: `serve` with neither flag. Every other caller passes none. */ serveFlags?: { readonly dev?: boolean; readonly preset?: string }; + /** + * [#22506] Also compose what `os serve` mounts AROUND the stack — the auth + * family behind its auth gate, the provider of every capability its resolver + * mounts (the stack's `requires` and the always-on slate), and the REST API + * plugin — each for its declarations only. See {@link composeServedPlatform}. + * `artifactRequires` is the compiled artifact's `requires` as the standalone + * stack surfaced it. + * + * Set by `os migrate plan` and `os migrate apply`, whose subject is the + * deployment's whole object set. Unset (`os migrate + * security-catalog-overlays`, which also boots NON-deferred under `--apply`): + * a provider is composed only when a composed plugin hard-depends on it + * (#21732), as before. + */ + servedPlatform?: { artifactRequires?: readonly string[] }; }): Promise { const cwd = opts.cwd ?? process.cwd(); const hostConfigPath = findHostConfig(cwd); @@ -1508,6 +1566,14 @@ export async function buildSchemaMigrationPlugins(opts: { // every CLI invocation (see `schema-migrate.ts`'s lazy-import note). const { loadConfig } = await import('./config.js'); const { config } = await loadConfig(hostConfigPath); + // [#22506] The pinyin-search decision `serve` stamps from THIS config's + // locales (`stampSearchPinyinEnabled`, `@objectstack/types` — the one + // helper both boots call, #3955). `createStandaloneStack` stamps from a + // compiled artifact only, so a config-only project planned a schema view + // without the `__search` companion columns its boot provisions, and listed + // every one of them as a destructive drop. Stamped before the kernel + // starts, where every registry reads it. + stampSearchPinyinEnabled((config as { i18n?: unknown } | null)?.i18n); // [#22288] `serve`'s rule for the tokens, as well as its lookup: the // top-level `requires`, otherwise each package body's. A multi-package @@ -1519,9 +1585,21 @@ export async function buildSchemaMigrationPlugins(opts: { // under `--dev` — by `serve`'s own merge. const hostPlugins: unknown[] = stackBootPlugins(config, opts.serveFlags?.dev); loadedConfig = config; - loadedHostPlugins = hostPlugins; - for (const plugin of hostPlugins) { - if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin, lifecycle)); + // [#22506] Each entry by `serve`'s rule for one (`materializeStackPlugin`, + // `@objectstack/core`) — a string is a package specifier, a plain bundle + // is wrapped into `AppPlugin`, an instance is itself. This loop used to + // keep the objects and drop the rest: a string entry vanished from the + // plan without a word, and a bundle reached the kernel with no `init`. + const materialized = await materializeHostPlugins(hostPlugins, { + hostRoot: path.dirname(hostConfigPath), + lifecycle, + skipSeedData: opts.skipSeedData ?? false, + }); + loadedHostPlugins = materialized.map((m) => m.plugin); + for (const { plugin, wrappedBundle } of materialized) { + // A bundle this composition wrapped is an `AppPlugin` it constructed + // itself, composed like the config's own below; an instance is host code. + plugins.push(wrappedBundle ? plugin : composeForDeclarations(plugin as object, lifecycle)); } // `serve` step 3, same predicate: a host config that ALSO carries @@ -1602,11 +1680,33 @@ export async function buildSchemaMigrationPlugins(opts: { notes.push('Composed PlatformObjectsPlugin (the platform floor `os serve` composes unconditionally).'); } + // [#22506] `os migrate plan` / `apply`: the platform `os serve` composes + // around this stack — the auth family behind its auth gate, and the provider + // of every capability its resolver mounts — through the shared rules. See + // {@link composeServedPlatform}. Not on an unloadable config: there is no + // stack to read, and that path is refused on its own terms (#12953). + if (opts.servedPlatform) { + if (!(hostConfigPath && !hostConfigLoaded)) { + const served = await composeServedPlatform({ + config: loadedConfig ?? {}, + hostPlugins: loadedHostPlugins, + basePlugins: opts.basePlugins, + composed: [...opts.basePlugins, ...plugins], + artifactRequires: opts.servedPlatform.artifactRequires, + packageRoot: hostConfigPath ? path.dirname(hostConfigPath) : cwd, + }); + // The auth family where `serve` registers it — ahead of the host plugins, + // so a config's own instance of the same name supersedes it here as + // there (the slot #22371's security plugin takes, below). + plugins.splice(1, 0, ...served.authFamily); + plugins.push(...served.plugins); + notes.push(...served.notes); + } // #21732 — `serve` step 5, narrowed to what this boot cannot start without: // a provider the config's `requires` supplies, that a composed plugin // hard-depends on. Only when the config LOADED — an unloadable config has // no `requires` to read, and that path is refused on its own terms. - if (hostConfigLoaded && hostConfigPath) { + } else if (hostConfigLoaded && hostConfigPath) { const resolved = await resolveRequiredProviders({ requires: loadedRequires, composed: [...opts.basePlugins, ...plugins], @@ -1644,6 +1744,257 @@ export async function buildSchemaMigrationPlugins(opts: { }; } +/** One `plugins` entry as {@link materializeHostPlugins} resolved it. */ +interface MaterializedHostPlugin { + /** The plugin the entry stands for. */ + plugin: unknown; + /** The entry was a plain bundle, and this composition wrapped it into an `AppPlugin`. */ + wrappedBundle: boolean; +} + +/** + * [#22506] The host config's `plugins` entries, each resolved by `os serve`'s + * rule for an entry: `materializeStackPlugin` (`@objectstack/core`), the one + * rule `serve` and `@objectstack/verify`'s `bootStack` mount the same array by. + * + * Only the LOADING is this boot's own, as it is each boot's: + * + * - a string is a package specifier, loaded host-anchored through + * `Serve.importConfigPlugin`, the loader `serve`'s own boot loop calls (its + * relative-path refusal and its failure wrapper come with it). The command + * module is imported only when an entry IS a string, so a config of + * instances never loads it; + * - a plain bundle is wrapped into `AppPlugin` with the declaration boot's two + * answers for the config's own `AppPlugin`: the boot's seed setting, and no + * `onEnable` (#21054, recorded on `lifecycle`). + * + * An absent entry (`undefined`, `null`, `false` — a conditional spread's hole) + * is skipped, as before. Anything else that cannot be loaded, or that does not + * resolve to a plugin object, THROWS. `serve` logs such an entry and boots on; a + * plan that did the same would be missing that plugin's objects and still read + * as coverage. The throw lands in the caller's unloadable-config path, so the + * plan prints what it could and then exits non-zero (#12953), and `apply` + * refuses before any DDL (#13118). + */ +async function materializeHostPlugins( + entries: readonly unknown[], + opts: { hostRoot: string; lifecycle: DeclarationBootLifecycle; skipSeedData: boolean }, +): Promise { + const { materializeStackPlugin } = await import('@objectstack/core'); + const out: MaterializedHostPlugin[] = []; + for (const [index, entry] of entries.entries()) { + if (entry === undefined || entry === null || entry === false) continue; + const at = typeof entry === 'string' ? `plugins[${index}] ('${entry}')` : `plugins[${index}]`; + let wrapped: unknown; + let plugin: unknown; + try { + plugin = await materializeStackPlugin(entry, { + importSpecifier: async (specifier) => { + const { default: Serve } = await import('../commands/serve.js'); + return Serve.importConfigPlugin(specifier, opts.hostRoot); + }, + wrapBundle: async (bundle) => { + const { AppPlugin } = await import('@objectstack/runtime'); + const app = new AppPlugin(bundle as any, undefined, { + skipSeedData: opts.skipSeedData, + skipOnEnable: true, + }); + opts.lifecycle.trackApp(app); + wrapped = app; + return app; + }, + }); + } catch (error: any) { + throw new Error(`its ${at} could not be loaded (${error?.message ?? String(error)})`); + } + if (!plugin || typeof plugin !== 'object') { + throw new Error( + `its ${at} resolves to ${plugin === null ? 'null' : typeof plugin}, not a plugin instance — ` + + '`os serve` cannot register it either', + ); + } + out.push({ plugin, wrappedBundle: plugin === wrapped }); + } + return out; +} + +/** + * [#22506] What `os serve` composes AROUND a stack, for `os migrate plan` and + * `os migrate apply` — read through the rules `serve` itself reads, and each + * piece composed for its declarations only. + * + * ## The defect + * + * A migration only examines the objects its boot REGISTERS, and this + * composition stopped at the stack: the host config's plugins, its metadata, + * the platform floor and — since #21732 — a provider only when a composed + * plugin hard-depended on it. Everything `serve` adds around a stack was + * missing. Measured on #22506 with an app declaring `requires: ['auth']` over a + * database a real `os serve` boot had created: the boot created 68 tables, the + * plan examined 9, and `sys_account` read as an undeclared platform table — so + * the retired `sys_account.issuer` column was never a destructive drop, while + * the boot's own drift line and `os migrate account-issuer` kept prescribing + * `os migrate apply --allow-destructive`. A loop with no exit, and the third + * time this family surfaced (#12938, #21732), each fix having grown one list. + * + * ## What is composed, and whose rule each piece is + * + * - **The auth family behind `serve`'s auth gate** (`serve` 5d): + * `resolvePlatformAuthComposition` (`@objectstack/core`), fed the tiers + * `resolveStackTiers` derives and the secret `resolveAuthSecret` resolves, + * exactly as `serve` and `os migrate security-catalog-overlays` feed it. + * Behind the gate `serve` mounts `AuthPlugin` with the security and audit + * plugins beside it. `AuthPlugin`'s objects are composed through its + * declaration twin, `createIdentityObjectsPlugin()` (`@objectstack/plugin-auth`): + * the same manifest under the same package id, and no authentication — this + * boot never signs anyone in, so it needs no secret value and constructs no + * auth manager. Registered where `serve` registers the family, ahead of the + * host plugins, so a config's own instance of the same name supersedes it. + * - **The capability providers** (`serve` 5): the stack's `requires`, read by + * `serve`'s rule (a host config's own; otherwise the compiled artifact's when + * it declares any; otherwise the config's), plus the always-on slate every + * served boot mounts (`PLATFORM_ALWAYS_ON_CAPABILITIES`, `@objectstack/spec`) + * — looked up in `CAPABILITY_PROVIDERS` and skipped when already held, by + * `providesCapability`'s exact identity match (`@objectstack/core`). The + * slate is `serve`'s with no `--preset`: these commands take no preset, as + * they take no `--dev`. + * - **The REST API plugin**, which `serve` composes on every boot and whose + * `init()` declares `sys_import_job` (#22202). + * + * ## How each provider runs: its `init()`, nothing else + * + * {@link composeProviderForDeclarations}: `start()` suppressed and NO lifecycle + * hook registered — the measured reason is on that function. A token with a + * {@link DECLARATION_PROVIDER_POSTURES} row keeps the posture measured for it + * (`automation`: the engine up, nothing armed, #21732). + * + * ## What this does NOT compose, stated + * + * `serve`'s flags (`--preset minimal` mounts no slate; `--dev` adds the + * config's `devPlugins`); its tier-gated AI and i18n services and the rest of + * its boot that is no shared rule; and the `telemetry` sibling datasource a + * development boot provisions (ADR-0057 §3.6), so lifecycle-classed objects are + * examined against the primary database here. The parity pin + * (`commands/migrate/plan.boot-parity.integration.test.ts`) holds the object + * set a plan examines equal to the one a real `os serve` boot registers, per + * example app shape — so whatever this list misses fails one test instead of + * being found on an upgrade. + */ +async function composeServedPlatform(input: { + config: unknown; + /** The host config's plugins, materialized — what the auth gate reads beside the base stack's. */ + hostPlugins: readonly unknown[]; + basePlugins: readonly unknown[]; + /** Everything composed so far — what a provider counts as already held against. */ + composed: readonly unknown[]; + artifactRequires?: readonly string[]; + packageRoot: string; +}): Promise<{ authFamily: unknown[]; plugins: unknown[]; notes: string[] }> { + const { CAPABILITY_PROVIDERS, providesCapability } = await import('@objectstack/core'); + const { PLATFORM_ALWAYS_ON_CAPABILITIES } = await import('@objectstack/spec/kernel'); + const config = input.config ?? {}; + // `serve`'s `requires` — the rule {@link composeAuthGatedSecurity} states. + const requires = !isHostConfig(config) && Array.isArray(input.artifactRequires) + ? input.artifactRequires.filter((t): t is string => typeof t === 'string') + : stackDeclaredCapabilities(config); + const notes: string[] = []; + + // ── `serve` 5d — the auth family, behind its auth gate ────────────────── + const authFamily: unknown[] = []; + const declaredTiers = resolveStackCollection(config, 'tiers').filter((t): t is string => typeof t === 'string'); + const decision = resolvePlatformAuthComposition({ + plugins: [...input.basePlugins, ...input.hostPlugins], + tiers: resolveStackTiers({ declaredTiers, requires }), + secret: resolveAuthSecret({ isDev: isDevelopmentBoot(undefined) }), + }); + if (decision.composes) { + const { createIdentityObjectsPlugin } = await import('@objectstack/plugin-auth'); + const { SecurityPlugin, appSecurityPluginOptions } = await import('@objectstack/plugin-security'); + const { AuditPlugin } = await import('@objectstack/plugin-audit'); + authFamily.push( + composeProviderForDeclarations(createIdentityObjectsPlugin()), + composeProviderForDeclarations(new SecurityPlugin(appSecurityPluginOptions(config as any))), + composeProviderForDeclarations(new AuditPlugin()), + ); + } + notes.push(describeAuthFamilyComposition(decision.composes ? { composed: true } : { composed: false, reason: decision.reason })); + + // ── every boot — the REST API plugin (`sys_import_job`) ───────────────── + const plugins: unknown[] = []; + const all = (): unknown[] => [...input.composed, ...authFamily, ...plugins]; + const composedNames: string[] = []; + if (!all().some((p) => pluginName(p) === 'com.objectstack.rest.api')) { + const { createRestApiPlugin } = await import('@objectstack/rest'); + plugins.push(composeProviderForDeclarations(createRestApiPlugin({}) as object)); + composedNames.push('the REST API plugin'); + } + + // ── `serve` 5 — the capability providers: `requires` + the always-on slate ── + const construct = async (token: string, pkg: string, exportName: string): Promise => { + const mod = (await import(/* webpackIgnore: true */ pkg)) as Record; + const Ctor = mod[exportName] as (new (arg?: unknown) => unknown) | undefined; + if (typeof Ctor !== 'function') { + throw new Error(`Capability "${token}": ${pkg} did not export ${exportName}`); + } + const posture = DECLARATION_PROVIDER_POSTURES[token]; + if (posture && exportName === CAPABILITY_PROVIDERS[token]?.export) { + return new Ctor(posture({ packageRoot: input.packageRoot })); + } + return composeProviderForDeclarations(new Ctor() as object); + }; + for (const token of [...new Set([...requires, ...PLATFORM_ALWAYS_ON_CAPABILITIES])]) { + const spec = CAPABILITY_PROVIDERS[token]; + // A tier token (`auth`, `ui`, `i18n`, `ai`) or one no open package + // provides mounts nothing in `serve`'s resolver either. + if (!spec) continue; + if (providesCapability(all(), spec.identities)) continue; + plugins.push(await construct(token, spec.pkg, spec.export)); + composedNames.push(spec.export); + for (const extra of spec.extras ?? []) { + if (providesCapability(all(), extra.identities)) continue; + plugins.push(await construct(token, extra.pkg, extra.export)); + composedNames.push(extra.export); + } + } + if (composedNames.length > 0) { + notes.push( + `Composed what \`os serve\` mounts around this stack (with no --preset) — ${composedNames.join(', ')} — ` + + 'for their declarations only: init runs; start and every lifecycle hook do not.', + ); + } + return { authFamily, plugins, notes }; +} + +/** + * [#22506] What the auth gate answered for `os migrate plan` / `apply`, as an + * operator reads it — and, when the family was not composed for want of a + * secret, the command that does compose it. + */ +export function describeAuthFamilyComposition( + status: { readonly composed: true } | { readonly composed: false; readonly reason: PlatformAuthSkipReason }, +): string { + if (status.composed) { + return 'Composed the auth family `os serve` composes behind its auth gate (an auth secret resolves) — ' + + 'plugin-auth\'s identity objects (sys_user, sys_account, …), the security plugin and the audit plugin — ' + + 'for their declarations only.'; + } + if (status.reason === 'stack-supplies-auth') { + return 'Did not compose the platform auth family: the stack mounts its own AuthPlugin, composed with its plugins above.'; + } + if (status.reason === 'no-secret') { + return 'Did not compose the auth family: `os serve` composes none without an auth secret, and none is set here ' + + '(no OS_AUTH_SECRET, and not a development boot) — so plugin-auth\'s tables (sys_user, sys_account, …) ' + + 'are NOT in this plan. If the deployment serves with OS_AUTH_SECRET (from its environment or a .env file, ' + + 'which `os migrate` does not read), re-run with that OS_AUTH_SECRET exported.'; + } + const why: Record, string> = { + 'auth-tier-off': 'the `auth` tier is off: the declared `tiers`, else the default preset\'s, carry none, and no ' + + '`requires` token opens it', + 'host-kernel': 'a host kernel, whose auth is per project', + }; + return `Did not compose the auth family: \`os serve\` composes none here (${why[status.reason]}).`; +} + /** * [#22371] The security plugin `os serve` composes for this deployment, for its * declarations only — or why it composes none. From 306a75dcf22290781fdfeb217c0195e2a162130f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:03:41 +0000 Subject: [PATCH 2/6] fix(driver-sql): the deferred-DDL preview answers a rotated object from the rotator's facts A rotation-declared object (ADR-0057 P2) lives as its current shard plus a read view under the base name; the preview asked hasTable(base), false for a view, and listed create_table on every plan while the flush created nothing. The current shard and the read view present now means nothing is pending. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../src/sql-driver-deferred-ddl.test.ts | 72 +++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 25 +++++++ 2 files changed, 97 insertions(+) diff --git a/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts b/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts index 60b7bf61c6f..929c874c9dc 100644 --- a/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts @@ -284,3 +284,75 @@ describe('deferred initObjects does not ensure the database exists (#13028)', () expect(await (driver as any).knex.schema.hasTable('widgets')).toBe(true); }); }); + +/** + * [#22506] A rotation-declared object (ADR-0057 P2) is previewed from the + * rotator's own facts. + * + * The rotator keeps such an object as its CURRENT shard (`__r`) + * plus a read VIEW under the base name. The preview used to ask `hasTable` of + * the base name — false for a view — so it listed `create_table` on every + * plan, and the flush, which takes the sync's rotation branch, created + * nothing: `os migrate plan` could never read "in sync" on a database holding + * `sys_activity`, the audit plugin's rotated object. These cases pin both + * halves: an object that is already sharded previews no work, and one that + * has no shard yet still previews the `create_table` the flush performs. + */ +describe('a rotation-declared object previews from the rotator\'s facts (#22506)', () => { + let driver: SqlDriver; + + afterEach(async () => { + await driver.disconnect(); + }); + + const ROTATED = { + name: 'rot_audit', + fields: { summary: { type: 'text' } }, + lifecycle: { class: 'telemetry', storage: { strategy: 'rotation', shards: 3, unit: 'day' } }, + }; + + async function physical(d: SqlDriver): Promise> { + const raw: any = await (d as any).knex.raw( + "SELECT name, type FROM sqlite_master WHERE name LIKE 'rot_audit%' AND name NOT LIKE '%autoindex%'", + ); + const rows: Array<{ name: string; type: string }> = Array.isArray(raw) ? raw : raw?.rows ?? []; + return Object.fromEntries(rows.map((r) => [r.name, r.type])); + } + + it('already sharded — the current shard and the read view exist — previews no work', async () => { + driver = makeDriver(); + // The boot path: the rotator creates the current shard and the view. + await driver.initObjects([ROTATED]); + const shapes = await physical(driver); + // The shape the old probe misread: the base name is a VIEW, the data a shard. + expect(shapes.rot_audit).toBe('view'); + expect(Object.entries(shapes).filter(([n, t]) => /^rot_audit__r\d{8}$/.test(n) && t === 'table')).toHaveLength(1); + + driver.setDeferredDdl(true); + await driver.initObjects([ROTATED]); + expect(await driver.previewDeferredSchemaWork()).toEqual([]); + }); + + it('control: never sharded — previews create_table, and creates nothing', async () => { + driver = makeDriver(); + driver.setDeferredDdl(true); + await driver.initObjects([ROTATED]); + + expect(await driver.previewDeferredSchemaWork()).toEqual([ + { table: 'rot_audit', kind: 'create_table', columns: ['summary'] }, + ]); + expect(await physical(driver)).toEqual({}); + }); + + it('converges: once the flush has sharded it, the next preview is empty', async () => { + driver = makeDriver(); + driver.setDeferredDdl(true); + await driver.initObjects([ROTATED]); + await driver.flushDeferredSchemaDdl(); + expect((await physical(driver)).rot_audit).toBe('view'); + + driver.setDeferredDdl(true); + await driver.initObjects([ROTATED]); + expect(await driver.previewDeferredSchemaWork()).toEqual([]); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 0bcf7465c25..01edeb164c7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -13500,6 +13500,31 @@ export class SqlDriver implements IDataDriver { const declared = Object.entries(obj.fields ?? {}) .filter(([, field]) => fieldHasColumn(field ?? {})) .map(([name]) => name); + // [#22506] A rotation-declared object (ADR-0057 P2) is not a table under + // its base name: the rotator keeps it as the CURRENT shard + // (`
__r`) plus a read VIEW under the base name, so the + // `hasTable` probe below — false for a view — listed it as `create_table` + // on every plan, and the flush (which takes the sync's rotation branch) + // then created nothing: a finding no apply could clear. It is answered + // from the rotator's own facts instead — its shard key for now, and the + // two names in `sqlite_master`: the current shard and the read view + // present means nothing is pending. Anything else falls through to the + // probes below (no shard yet: `create_table`, which the flush performs). + // The deferral stores the whole definition (`{ ...obj, name }`), so its + // `lifecycle` is here even though the map's declared value type omits it. + const rotation = (obj as { lifecycle?: { storage?: { strategy?: string; unit?: 'day' | 'week' | 'month' } } }) + .lifecycle?.storage; + if (rotation?.strategy === 'rotation' && rotation.unit && this.supportsRotation) { + const current = `${tableName}__r${this.rotationShardKey(Date.now(), rotation.unit)}`; + const raw: any = await this.knex.raw( + 'SELECT name, type FROM sqlite_master WHERE name IN (?, ?)', + [tableName, current], + ); + const rows: Array<{ name: string; type: string }> = Array.isArray(raw) ? raw : raw?.rows ?? []; + const readView = rows.some((r) => r.name === tableName && r.type === 'view'); + const currentShard = rows.some((r) => r.name === current && r.type === 'table'); + if (readView && currentShard) continue; + } if (!(await this.knex.schema.hasTable(tableName))) { // A table that does not exist yet is created empty, so nothing to converge. out.push({ table: tableName, kind: 'create_table', columns: declared }); From 143f14e1929d4510484cf023d9f225ff3f663180 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:08:48 +0000 Subject: [PATCH 3/6] test(cli): pin the issuer retirement on requires-auth and plan/boot parity per app shape The hotclm shape over a legacy sys_account (plan names both drops, apply performs them, account-issuer reads zero, a re-plan is in sync), the parity pin against a real os serve provision per shape, unit cases for the served composition, the module header, the changeset, and account-issuer's clean prescription made true whether or not the column is still there. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/22506-migrate-composes-boot.md | 24 ++ .../src/commands/migrate/account-issuer.ts | 2 +- ...ount-issuer-retirement.integration.test.ts | 236 ++++++++++++++++++ .../plan.boot-parity.integration.test.ts | 194 ++++++++++++++ .../utils/schema-migration-plugins.test.ts | 185 +++++++++++++- .../cli/src/utils/schema-migration-plugins.ts | 47 ++-- 6 files changed, 668 insertions(+), 20 deletions(-) create mode 100644 .changeset/22506-migrate-composes-boot.md create mode 100644 packages/cli/src/commands/migrate/apply.account-issuer-retirement.integration.test.ts create mode 100644 packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts diff --git a/.changeset/22506-migrate-composes-boot.md b/.changeset/22506-migrate-composes-boot.md new file mode 100644 index 00000000000..211ef3442cf --- /dev/null +++ b/.changeset/22506-migrate-composes-boot.md @@ -0,0 +1,24 @@ +--- +'@objectstack/cli': patch +'@objectstack/driver-sql': patch +--- + +fix(cli): `os migrate plan` / `apply` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) + +`os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. + +The two commands now also compose, each through the rule `os serve` itself reads: + +- the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; +- the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; +- the REST API plugin; +- every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; +- the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. + +Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. + +**What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. + +**driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. + +**Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts index bfc270d279f..1ede3dddd97 100644 --- a/packages/cli/src/commands/migrate/account-issuer.ts +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -199,7 +199,7 @@ export default class MigrateAccountIssuer extends Command { if (report.ok) { printSuccess( - 'Pre-flight clean. Take a backup, then run "os migrate apply --allow-destructive" to drop the column.', + 'Pre-flight clean. While "os migrate plan" still lists the sys_account.issuer drop, take a backup and run "os migrate apply --allow-destructive"; once it lists none, the retirement is done.', ); console.log(chalk.dim(` ${timer.display()}`)); return; diff --git a/packages/cli/src/commands/migrate/apply.account-issuer-retirement.integration.test.ts b/packages/cli/src/commands/migrate/apply.account-issuer-retirement.integration.test.ts new file mode 100644 index 00000000000..2ee4bcdbc8f --- /dev/null +++ b/packages/cli/src/commands/migrate/apply.account-issuer-retirement.integration.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { 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 Database from 'better-sqlite3'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysAccount } from '@objectstack/platform-objects/identity'; +import { isExitSignal } from '../../utils/format.js'; +import MigratePlan from './plan.js'; +import MigrateApply from './apply.js'; +import MigrateAccountIssuer from './account-issuer.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the +// commands reach through dynamic `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/runtime'; +import '@objectstack/objectql'; +import '@objectstack/plugin-auth'; +import '@objectstack/plugin-security'; +import '@objectstack/plugin-audit'; + +/** + * [#22506] The `sys_account.issuer` retirement, end to end on the shape that + * looped: an app declaring `requires: ['auth']`, over a database whose + * `sys_account` still carries the retired `issuer` column and its unique index. + * + * ## The loop this closes + * + * `os migrate plan` / `apply` composed the stack and stopped there: nothing of + * what `os serve` mounts around it, so `sys_account` (plugin-auth's object, + * behind `serve`'s auth gate) was never a registered object, the retired column + * was never a destructive drop, and `apply --allow-destructive` dropped nothing + * — while the boot's drift line and `os migrate account-issuer` kept + * prescribing exactly that command. Measured on a 17.4.0-created SQLite + * database (objectstack-ai/hotclm#82) and reproduced on #22506. + * + * ## The fixture is independent of the code under test + * + * `sys_account` is created by the driver from the object definition that ships + * today, then given the legacy shape by hand: the `issuer` column and the + * `uniq_sys_account_issuer_account_id` index. No `os migrate` composition is + * involved in building it. One account row carries an issuer, so the + * retirement pre-flight has something to read. + * + * The four pins run in order over ONE database, as an operator runs the steps: + * the plan declares `sys_account` and names both drops; apply performs them; + * the pre-flight reaches zero; and a re-plan is in sync — in particular no + * `sys_activity` `create_table` that no apply can clear (the rotation-declared + * object the composed audit plugin registers, previewed from the rotator's + * facts since this card). + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; +const SECRET = 'os22506-integration-secret-at-least-32-chars'; +const LEGACY_INDEX = 'uniq_sys_account_issuer_account_id'; + +type Invoke = (argv: string[]) => Promise; +const invoke = (command: { run: (argv: string[], opts: { root: string }) => Promise }): Invoke => + (argv) => command.run(argv, { root: CLI_ROOT }); + +/** Env that would point a command's boot somewhere other than the fixture. */ +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', + 'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', +] as const; + +/** + * Run one command and capture its JSON document (the one-shot family's + * shape: stdout spied before the boot, `process.exit` and oclif's exit signal + * trapped, `process.exitCode` restored). + */ +async function runJson(command: Invoke, 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, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + let thrownExit: number | undefined; + try { + try { + await command(argv); + } catch (error) { + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (!trapped && !isExitSignal(error)) throw error; + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { + payload = JSON.parse(out); + } catch { + payload = { unparsed: out.slice(-400) }; + } + return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +let dir: string; +let dbFile: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +function accountShape(): { columns: string[]; indexes: string[] } { + const db = new Database(dbFile, { readonly: true }); + try { + return { + columns: (db.prepare("PRAGMA table_info('sys_account')").all() as Array<{ name: string }>).map((c) => c.name), + indexes: (db.prepare("PRAGMA index_list('sys_account')").all() as Array<{ name: string }>).map((i) => i.name), + }; + } finally { + db.close(); + } +} + +beforeAll(async () => { + for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key]; + for (const key of OVERRIDING_ENV) delete process.env[key]; + dir = mkdtempSync(join(tmpdir(), 'os-22506-issuer-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + 'export default {', + " manifest: { id: 'com.example.os22506issuer', name: 'requires auth', version: '0.0.0', type: 'app' },", + " requires: ['auth'],", + " objects: [{ name: 'os22506_issuer_thing', fields: { title: { type: 'text' } } }],", + '};', + '', + ].join('\n'), + ); + // Config-only on purpose: the hotclm shape has no compiled artifact here. + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + // `os serve` composes the auth family only when an auth secret resolves; the + // deployment this pins serves with one. + process.env.OS_AUTH_SECRET = SECRET; + process.env.NODE_ENV = 'production'; + process.env.OS_TELEMETRY_DB = '0'; + dbFile = join(dir, 'legacy.db'); + + // The legacy `sys_account`, built by the driver from today's definition. + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + try { + await driver.initObjects([SysAccount as any]); + } finally { + await driver.disconnect(); + } + const db = new Database(dbFile); + try { + db.exec('ALTER TABLE sys_account ADD COLUMN issuer TEXT'); + db.exec(`CREATE UNIQUE INDEX ${LEGACY_INDEX} ON sys_account (issuer, account_id)`); + db.prepare( + 'INSERT INTO sys_account (id, provider_id, account_id, user_id, issuer) VALUES (?, ?, ?, ?, ?)', + ).run('acc_os22506', 'credential', 'usr_os22506', 'usr_os22506', 'local:credential'); + } finally { + db.close(); + } + expect(accountShape().columns, 'fixture: the legacy column').toContain('issuer'); +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } +}); + +beforeEach(() => { process.chdir(dir); }); +afterEach(() => { process.chdir(savedCwd); }); + +describe('the sys_account.issuer retirement on an app with requires: [\'auth\'] (#22506)', () => { + const db = () => `file:${dbFile}`; + + it('plan declares sys_account and names the column and its index as destructive drops', async () => { + const { payload, exitCode } = await runJson(invoke(MigratePlan), ['--database-url', db(), '--json']); + expect(exitCode).toBe(0); + const drops = (payload.changes ?? []) + .filter((c: any) => c.table === 'sys_account' && c.category === 'destructive') + .map((c: any) => `${c.op?.type}:${c.op?.indexName ?? c.column}`) + .sort(); + expect(drops).toEqual([`drop_column:issuer`, `drop_index:${LEGACY_INDEX}`]); + // Declared, not swept: the table is no longer "what exists that nothing declares". + expect(JSON.stringify(payload.unmanagedTables?.tables ?? [])).not.toContain('sys_account'); + expect((payload.composition?.notes ?? []).join(' ')).toContain('Composed the auth family'); + }, CASE_TIMEOUT_MS); + + it('apply --allow-destructive drops the column and its unique index', async () => { + const { payload, exitCode } = await runJson(invoke(MigrateApply), [ + '--database-url', db(), '--allow-destructive', '--yes', '--json', + ]); + expect(exitCode).toBe(0); + const applied = (payload.applied ?? []) + .filter((c: any) => c.table === 'sys_account') + .map((c: any) => `${c.op?.type}:${c.op?.indexName ?? c.column}`) + .sort(); + expect(applied).toEqual([`drop_column:issuer`, `drop_index:${LEGACY_INDEX}`]); + const shape = accountShape(); + expect(shape.columns).not.toContain('issuer'); + expect(shape.indexes).not.toContain(LEGACY_INDEX); + }, CASE_TIMEOUT_MS); + + it('account-issuer reaches zero', async () => { + const { payload, exitCode } = await runJson(invoke(MigrateAccountIssuer), ['--database-url', db(), '--json']); + expect(exitCode).toBe(0); + expect(payload.ok).toBe(true); + expect(payload.collisions).toEqual([]); + expect(payload.scanned).toBe(1); + }, CASE_TIMEOUT_MS); + + it('a re-plan after apply is in sync — no drift and nothing pending, sys_activity included', async () => { + const { payload, exitCode } = await runJson(invoke(MigratePlan), ['--database-url', db(), '--json']); + expect(exitCode).toBe(0); + expect(payload.changes).toEqual([]); + expect(payload.pending).toEqual([]); + expect(payload.composition?.coverage?.unexaminedObjects).toBe(0); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts b/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts new file mode 100644 index 00000000000..c10b313edd5 --- /dev/null +++ b/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { 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 Database from 'better-sqlite3'; + +/** + * [#22506] THE PIN THAT CLOSES THE FAMILY: per app shape, the set of objects + * `os migrate plan` examines equals the set a real `os serve` boot registers. + * + * ## The family + * + * A migration only examines the objects its own boot registers, and that + * composition has lagged the serving boot three times: #12938 (no host config, + * no platform floor), #21732 (a `requires`-supplied provider a plugin depended + * on), #22506 (nothing `serve` mounts around the stack — the auth family behind + * its auth gate, its capability providers, its REST plugin — so an app with + * `requires: ['auth']` planned 9 of the 68 tables its boot created, and the + * retired `sys_account.issuer` was never a drop). Each fix grew one list. This + * pin makes the next gap fail one test instead of an upgrade. + * + * ## How "the boot registers" is read + * + * There is no in-process entry to `serve`'s composition — it is the oclif + * command's own `run()` — and `@objectstack/verify`'s `bootStack` composes the + * verification harness's set, not `serve`'s. So the boot side is `serve` + * itself, through its own provision-and-exit door: `OS_MIGRATE_AND_EXIT=1` + * boots the full composition, schema-syncs every registered object into a + * FRESH database and exits. Its tables are its registered objects. The plan + * then runs against that database, and three readings together are equality: + * + * - `pending` is empty — every object the plan examines has the table the + * boot created (plan ⊆ boot); + * - the unmanaged-tables sweep names nothing but `sys_packages` — no platform + * table the boot created is undeclared by the plan; + * - the boot's tables, a rotation shard read as its object, number exactly + * the plan's examined objects plus `sys_packages` (boot ⊆ plan, app tables + * included). + * + * `sys_packages` is the one table outside the object set, by construction: + * `PackageServicePlugin.start()` creates it with raw DDL (`package-table.ts`), + * and no object declares it. A new raw-DDL table fails this pin and has to be + * named here with the same reason. + * + * Both sides run with `OS_TELEMETRY_DB=0`: a development boot's `telemetry` + * sibling (ADR-0057 §3.6) is a second database the one-shot boot does not + * provision — a known, separate gap — and the pin compares object sets on one. + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js'); +const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx'); +const CASE_TIMEOUT_MS = 300_000; +const NOT_AN_OBJECT = ['sys_packages']; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } + } +}); + +/** The child's environment: the fixture's database and artifact path, nothing inherited that retargets them. */ +function childEnv(dir: string, dbFile: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'AUTH_SECRET', 'BETTER_AUTH_SECRET']) { + delete env[key]; + } + return { + ...env, + OS_DATABASE_URL: `file:${dbFile}`, + OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'), + OS_TELEMETRY_DB: '0', + OS_CLOUD_URL: 'off', + OS_TELEMETRY_DISABLED: '1', + OS_AUTH_SECRET: 'os22506-parity-secret-at-least-32-characters', + OS_LOG_LEVEL: 'warn', + }; +} + +interface Reading { + bootExit: number | null; + bootTail: string; + plan: any; + planExit: number | null; + tables: string[]; +} + +function bootThenPlan(dir: string): Reading { + const dbFile = join(dir, 'boot.db'); + const env = childEnv(dir, dbFile); + const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], { + cwd: dir, env: { ...env, OS_MIGRATE_AND_EXIT: '1' }, encoding: 'utf8', timeout: 240_000, + }); + const plan = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', '--json'], { + cwd: dir, env, encoding: 'utf8', timeout: 240_000, + }); + let payload: any; + try { payload = JSON.parse(plan.stdout); } catch { payload = { unparsed: `${plan.stdout}\n${plan.stderr}`.slice(-800) }; } + const db = new Database(dbFile, { readonly: true }); + let tables: string[]; + try { + tables = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all() as Array<{ name: string }>) + .map((t) => t.name.replace(/__r\d{6,8}$/, '')); + } finally { + db.close(); + } + return { + bootExit: boot.status, + bootTail: `${boot.stdout}\n${boot.stderr}`.slice(-800), + plan: payload, + planExit: plan.status, + tables: [...new Set(tables)].sort(), + }; +} + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'os-22506-parity-')); + dirs.push(dir); + mkdirSync(join(dir, 'dist'), { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); + return dir; +} + +function expectParity(r: Reading): void { + expect(r.bootExit, `the boot did not provision the database:\n${r.bootTail}`).toBe(0); + expect(r.planExit, JSON.stringify(r.plan).slice(0, 800)).toBe(0); + // plan ⊆ boot + expect(r.plan.pending).toEqual([]); + expect(r.plan.changes).toEqual([]); + expect(r.plan.composition?.coverage?.unexaminedObjects).toBe(0); + // boot ⊆ plan, platform tables by name … + const unmanaged = (r.plan.unmanagedTables?.tables ?? []).map((t: any) => (typeof t === 'string' ? t : t.table ?? t.name)); + expect(unmanaged).toEqual(NOT_AN_OBJECT); + // … and every table by count: the boot's tables are the examined objects plus the raw-DDL ones. + expect(r.tables.length).toBe(r.plan.managedTables + NOT_AN_OBJECT.length); + expect(r.tables).toEqual(expect.arrayContaining(NOT_AN_OBJECT)); +} + +describe('os migrate plan examines exactly what os serve registers, per app shape (#22506)', () => { + it('an app with requires: [...] — the auth tier and a capability provider', () => { + const dir = project({ + 'objectstack.config.ts': [ + 'export default {', + " manifest: { id: 'com.example.os22506parityapp', name: 'requires', version: '0.0.0', type: 'app' },", + " requires: ['auth', 'automation'],", + " objects: [{ name: 'os22506_parity_app', fields: { title: { type: 'text' } } }],", + '};', + '', + ].join('\n'), + }); + const r = bootThenPlan(dir); + expectParity(r); + expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_app', 'sys_account', 'sys_automation_run'])); + }, CASE_TIMEOUT_MS); + + it('a host config — instances in plugins, declaring through the manifest service', () => { + const dir = project({ + 'objectstack.config.ts': [ + 'class HostPlugin {', + " name = 'com.example.os22506.host';", + ' async init(ctx: any) {', + " ctx.getService('manifest').register({", + " id: 'com.example.os22506.host', name: 'host', version: '0.0.0', type: 'plugin',", + " objects: [{ name: 'os22506_parity_host', fields: { title: { type: 'text' } } }],", + ' });', + ' }', + '}', + 'export default { plugins: [new HostPlugin()] };', + '', + ].join('\n'), + }); + const r = bootThenPlan(dir); + expectParity(r); + expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_host', 'sys_account'])); + }, CASE_TIMEOUT_MS); + + it('a standalone stack — a compiled artifact and no config', () => { + const dir = project({}); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify({ + manifest: { id: 'com.example.os22506parityartifact', name: 'artifact', version: '0.0.0', type: 'app' }, + requires: ['auth'], + objects: [{ name: 'os22506_parity_artifact', fields: { title: { type: 'text' } } }], + })); + const r = bootThenPlan(dir); + expectParity(r); + expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_artifact', 'sys_account'])); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts index d266b911d19..9dfd955dbff 100644 --- a/packages/cli/src/utils/schema-migration-plugins.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -1,12 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { findHostConfig, composeForDeclarations, + composeProviderForDeclarations, createDeclarationBootLifecycle, buildSchemaMigrationPlugins, measureComposedCoverage, @@ -747,3 +748,185 @@ describe('measureComposedCoverage (#13028)', () => { expect(out.notes.join(' ')).toContain('UNMEASURED'); }); }); + +/** + * [#22506] What `os migrate plan` / `apply` compose AROUND the stack + * (`servedPlatform`): the auth family behind `serve`'s auth gate, the provider + * of every capability `serve` mounts, the REST API plugin — each for its + * declarations only — plus the entry rule and the pinyin stamp the config path + * shares with `serve`. The boot-level proof is + * `commands/migrate/plan.boot-parity.integration.test.ts`; these cases pin each + * decision on its own. + */ +describe('servedPlatform (#22506)', () => { + const ENV_KEYS = ['OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'NODE_ENV', 'OS_SEARCH_PINYIN_ENABLED'] as const; + const saved: Partial> = {}; + beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + for (const key of ['OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_SEARCH_PINYIN_ENABLED'] as const) { + delete process.env[key]; + } + process.env.NODE_ENV = 'production'; + }); + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + const SECRET = 'os22506-unit-secret-at-least-32-characters'; + const AUTH_FAMILY = ['com.objectstack.auth.identity-objects', 'com.objectstack.security', 'com.objectstack.audit']; + + function project(config: string): string { + const dir = tempProject(); + writeFileSync(join(dir, 'objectstack.config.ts'), config); + return dir; + } + const APP = [ + 'export default {', + " manifest: { id: 'com.example.os22506unit', name: 'served platform', version: '0.0.0', type: 'app' },", + " requires: ['auth'],", + " objects: [{ name: 'os22506_thing', fields: { title: { type: 'text' } } }],", + '};', + '', + ].join('\n'); + + it('composes the auth family behind the gate, ahead of the host plugins — plugin-auth\'s objects through its declaration twin', async () => { + process.env.OS_AUTH_SECRET = SECRET; + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP), servedPlatform: {} }); + const names = out.plugins.map((p: any) => p?.name); + // Where `serve` registers the family: right behind the write guard, so a + // config's own instance of one of these names would supersede it. + expect(names.slice(0, 4)).toEqual([WRITE_GUARD, ...AUTH_FAMILY]); + // No AuthPlugin: this boot signs nobody in and constructs no auth manager. + expect(names).not.toContain('com.objectstack.auth'); + expect(out.notes.join(' ')).toContain('Composed the auth family `os serve` composes behind its auth gate'); + }, 60_000); + + it('composes the provider of every capability `serve` mounts — `requires` and the always-on slate — and the REST API plugin', async () => { + process.env.OS_AUTH_SECRET = SECRET; + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP), servedPlatform: {} }); + const ctors = out.plugins.map((p: any) => p?.constructor?.name); + for (const provider of [ + 'QueueServicePlugin', 'JobServicePlugin', 'CacheServicePlugin', 'SettingsServicePlugin', 'EmailServicePlugin', + 'StorageServicePlugin', 'SmsServicePlugin', 'SharingServicePlugin', 'MessagingServicePlugin', + 'AnalyticsServicePlugin', 'PackageServicePlugin', + ]) { + expect(ctors, provider).toContain(provider); + } + expect(out.plugins.map((p: any) => p?.name)).toContain('com.objectstack.rest.api'); + }, 60_000); + + it('a provider the host already supplies wins, as under `serve` — never a second instance', async () => { + process.env.OS_AUTH_SECRET = SECRET; + const dir = project([ + "class OwnQueue { name = 'com.objectstack.service.queue'; async init() {} }", + 'export default { plugins: [new OwnQueue()] };', + '', + ].join('\n')); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir, servedPlatform: {} }); + expect(out.plugins.filter((p: any) => p?.name === 'com.objectstack.service.queue')).toHaveLength(1); + expect(out.plugins.some((p: any) => p?.constructor?.name === 'QueueServicePlugin')).toBe(false); + }, 60_000); + + it('no secret and not a development boot: no auth family, and the note names the command that composes it', async () => { + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP), servedPlatform: {} }); + const names = out.plugins.map((p: any) => p?.name); + for (const name of AUTH_FAMILY) expect(names).not.toContain(name); + const said = out.notes.join(' '); + expect(said).toContain('Did not compose the auth family'); + expect(said).toContain('re-run with that OS_AUTH_SECRET exported'); + }, 60_000); + + it('a stack that mounts its own AuthPlugin gets no platform family', async () => { + process.env.OS_AUTH_SECRET = SECRET; + const dir = project([ + "class OwnAuth { name = 'com.objectstack.auth'; async init() {} }", + 'export default { plugins: [new OwnAuth()] };', + '', + ].join('\n')); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir, servedPlatform: {} }); + const names = out.plugins.map((p: any) => p?.name); + for (const name of AUTH_FAMILY) expect(names).not.toContain(name); + expect(out.notes.join(' ')).toContain('the stack mounts its own AuthPlugin'); + }, 60_000); + + it('a `plugins` bundle entry is wrapped into an AppPlugin, by `serve`\'s entry rule — not composed raw', async () => { + const dir = project([ + 'export default { plugins: [', + " { manifest: { id: 'com.example.os22506bundle', name: 'bundle', version: '0.0.0', type: 'app' },", + " objects: [{ name: 'os22506_bundled', fields: { title: { type: 'text' } } }] },", + '] };', + '', + ].join('\n')); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + const bundled = out.plugins.find((p: any) => p?.name === 'plugin.app.com.example.os22506bundle') as any; + expect(bundled?.constructor?.name).toBe('AppPlugin'); + }, 60_000); + + it('a `plugins` entry that cannot be loaded makes the config unloadable, naming the entry', async () => { + const dir = project("export default { plugins: ['@objectstack-fixture/os22506-absent'] };\n"); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.hostConfigLoaded).toBe(false); + expect(out.hostConfigError).toContain("its plugins[0] ('@objectstack-fixture/os22506-absent') could not be loaded"); + }, 60_000); + + it('stamps the config\'s pinyin-search decision, as `serve` does', async () => { + const dir = project([ + 'export default {', + " objects: [{ name: 'os22506_zh', fields: { title: { type: 'text' } } }],", + " i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },", + '};', + '', + ].join('\n')); + await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(process.env.OS_SEARCH_PINYIN_ENABLED).toBe('true'); + }, 60_000); + + it('control: without servedPlatform nothing around the stack is composed', async () => { + process.env.OS_AUTH_SECRET = SECRET; + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: project(APP) }); + expect(out.plugins.map((p: any) => p?.name)).toEqual([ + WRITE_GUARD, + 'plugin.app.com.example.os22506unit', + 'com.objectstack.platform-objects', + ]); + }, 60_000); +}); + +describe('composeProviderForDeclarations (#22506)', () => { + it('runs init with a context that registers NO hook — kernel:ready included — and suppresses start', async () => { + const registered: string[] = []; + const calls: string[] = []; + const provider = { + name: 'com.example.provider', + async init(ctx: any) { + calls.push('init'); + ctx.hook('kernel:ready', async () => { calls.push('dispatcher armed'); }); + ctx.hook('kernel:bootstrapped', async () => { /* never */ }); + ctx.registerService('svc', {}); + }, + async start() { calls.push('start'); }, + }; + const wrapped = composeProviderForDeclarations(provider); + const services: string[] = []; + await wrapped.init({ + hook: (name: string) => { registered.push(name); }, + registerService: (name: string) => { services.push(name); }, + } as any); + await wrapped.start(); + expect(calls).toEqual(['init']); + expect(registered).toEqual([]); + // Everything that is not a hook still reaches the kernel's context. + expect(services).toEqual(['svc']); + expect(wrapped.name).toBe('com.example.provider'); + }); + + it('control: the host posture still registers kernel:ready', async () => { + const registered: string[] = []; + const host = { name: 'com.example.host', async init(ctx: any) { ctx.hook('kernel:ready', async () => {}); } }; + await composeForDeclarations(host).init({ hook: (name: string) => { registered.push(name); } } as any); + expect(registered).toEqual(['kernel:ready']); + }); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 09729b53f95..3faa9f72fcf 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -54,35 +54,46 @@ import { * The set is derived from `serve`'s own assembly rather than hand-picked: * * - **The host config's plugins**, when an `objectstack.config.{ts,js,mjs}` is - * present — this is `serve`'s `plugins = config.plugins` step. On a - * deployment whose whole object set comes from its config (ObjectStack - * Cloud's control plane is the measured one: `createCloudStack()` returns the - * plugins, and the app has no compiled artifact at all) this is the ONLY half - * that matters. + * present — this is `serve`'s `plugins = config.plugins` step, each entry by + * `serve`'s rule for one (`materializeStackPlugin`, `@objectstack/core`; + * #22506, {@link materializeHostPlugins}). On a deployment whose whole object + * set comes from its config (ObjectStack Cloud's control plane is the + * measured one: `createCloudStack()` returns the plugins, and the app has no + * compiled artifact at all) this is the ONLY half that matters. * - **`AppPlugin(config)`**, when the config carries top-level metadata and * brings no `AppPlugin` of its own — `serve` step 3, same presence test * ({@link isAppPluginLike}), so top-level `objects`/`flows` reach the - * registry here exactly as they do there. - * - **`PlatformObjectsPlugin`**, when absent — `serve` step 5c. It is the one - * plugin `serve` composes UNCONDITIONALLY; everything else it injects - * (the auth family, i18n, observability, the HTTP server) sits behind a tier, - * an env var or a capability, and the auth family additionally behind "the - * config brought no `AuthPlugin`". Composing a tier-gated plugin here would - * be inventing an object set no boot of this deployment has. - * - **A `requires`-supplied provider a composed plugin hard-depends on** - * (#21732) — `serve` step 5's token lookup, narrowed to the providers the + * registry here exactly as they do there. The config's pinyin-search + * decision is stamped as `serve` stamps it (#22506), so the `__search` + * companion columns its boot provisions are in the schema view too. + * - **`PlatformObjectsPlugin`**, when absent — `serve` step 5c, composed on + * every served boot. + * - **What `os serve` mounts AROUND the stack — `os migrate plan` and `apply` + * only** (`servedPlatform`, #22506, {@link composeServedPlatform}): the auth + * family behind `serve`'s auth gate, the provider of every capability its + * resolver mounts (the stack's `requires` and the always-on slate), and the + * REST API plugin — each through the rule `serve` reads, each for its + * declarations only ({@link composeProviderForDeclarations}). Without it an + * app declaring `requires: ['auth']` planned 9 of the 68 tables its boot + * created, `sys_account` among the missing, so the retired + * `sys_account.issuer` column was never a drop and the upgrade step that + * prescribes one looped. + * - **Otherwise, a `requires`-supplied provider a composed plugin hard-depends + * on** (#21732) — `serve` step 5's token lookup, narrowed to the providers the * kernel cannot order the composition without, each in a measured inert * posture ({@link resolveRequiredProviders}). Without it every config that * lists a connector in `plugins` and `automation` in `requires` — the blank - * template and the showcase among them — could not boot this command. + * template and the showcase among them — could not boot this command. This + * is the path of the one caller that does not ask for `servedPlatform`. * - **The security plugin behind `serve`'s auth gate — only when the caller * asks** (`authGatedSecurity`, #22371). `os migrate * security-catalog-overlays` needs the permission-set names the deployment's * boot holds, and the security plugin declares its shipped sets only where * `serve`'s auth gate composes it. The gate is the one rule `serve` asks * (`resolvePlatformAuthComposition`, `@objectstack/core`), so this is not an - * invented object set: it is the one this deployment's boot has. `plan` and - * `apply` do not ask, and compose nothing tier-gated, as before. + * invented object set: it is the one this deployment's boot has. That + * command also boots NON-deferred under `--apply`, which is why it does not + * ask for the whole served platform: schema sync would create its tables. * * ## Phase 1 only for host plugins — and why that is the contract, not a dodge * @@ -487,7 +498,7 @@ export function composeForDeclarations( * dispatchers and seeding belong to a served boot. * * Whether every provider declares its objects in `init()` is not assumed: the - * parity pin (`migrate-plan-boot-parity.integration.test.ts`) compares the + * parity pin (`commands/migrate/plan.boot-parity.integration.test.ts`) compares the * object set a plan examines with the one a real `os serve` boot registers, per * example app shape, so a provider that declares from a hook fails one test. */ From 87603d48f75f69c3b8b4f4d7f17fbe69080db584 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:19:31 +0000 Subject: [PATCH 4/6] test(cli): the pinyin-stamp fixture is a valid app, and the case asserts its config loads Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/cli/src/utils/schema-migration-plugins.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts index 9dfd955dbff..26925473ce8 100644 --- a/packages/cli/src/utils/schema-migration-plugins.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -875,12 +875,14 @@ describe('servedPlatform (#22506)', () => { it('stamps the config\'s pinyin-search decision, as `serve` does', async () => { const dir = project([ 'export default {', + " manifest: { id: 'com.example.os22506zh', name: 'zh locales', version: '0.0.0', type: 'app' },", " objects: [{ name: 'os22506_zh', fields: { title: { type: 'text' } } }],", " i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },", '};', '', ].join('\n')); - await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.hostConfigLoaded).toBe(true); expect(process.env.OS_SEARCH_PINYIN_ENABLED).toBe('true'); }, 60_000); From 47823309b4a1ac73a3a00ba0bc1ba9cf0eea6c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 01:27:21 +0000 Subject: [PATCH 5/6] test(cli): the parity pin counts tables where the sweep cannot run, and the host-config shape carries its own metadata Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../plan.boot-parity.integration.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts b/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts index c10b313edd5..9be31058073 100644 --- a/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts +++ b/packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts @@ -35,11 +35,11 @@ import Database from 'better-sqlite3'; * * - `pending` is empty — every object the plan examines has the table the * boot created (plan ⊆ boot); - * - the unmanaged-tables sweep names nothing but `sys_packages` — no platform - * table the boot created is undeclared by the plan; * - the boot's tables, a rotation shard read as its object, number exactly * the plan's examined objects plus `sys_packages` (boot ⊆ plan, app tables - * included). + * included) — with `pending` empty, equal counts are equal sets; + * - where the unmanaged-tables sweep runs (a project with a host config), it + * names nothing but `sys_packages`. * * `sys_packages` is the one table outside the object set, by construction: * `PackageServicePlugin.start()` creates it with raw DDL (`package-table.ts`), @@ -134,12 +134,18 @@ function expectParity(r: Reading): void { expect(r.plan.pending).toEqual([]); expect(r.plan.changes).toEqual([]); expect(r.plan.composition?.coverage?.unexaminedObjects).toBe(0); - // boot ⊆ plan, platform tables by name … - const unmanaged = (r.plan.unmanagedTables?.tables ?? []).map((t: any) => (typeof t === 'string' ? t : t.table ?? t.name)); - expect(unmanaged).toEqual(NOT_AN_OBJECT); - // … and every table by count: the boot's tables are the examined objects plus the raw-DDL ones. - expect(r.tables.length).toBe(r.plan.managedTables + NOT_AN_OBJECT.length); - expect(r.tables).toEqual(expect.arrayContaining(NOT_AN_OBJECT)); + // boot ⊆ plan, every table by count: the boot's tables are the examined + // objects plus the raw-DDL ones. With `pending` empty above, equal counts + // are equal sets. + const rawDdl = r.tables.filter((t) => NOT_AN_OBJECT.includes(t)); + expect(rawDdl).toEqual(NOT_AN_OBJECT); + expect(r.tables.length).toBe(r.plan.managedTables + rawDdl.length); + // … and platform tables by name, where the unmanaged sweep runs (it reports + // itself `unreadable` on a project with no host config). + if (r.plan.unmanagedTables?.status === 'read') { + const unmanaged = (r.plan.unmanagedTables.tables ?? []).map((t: any) => (typeof t === 'string' ? t : t.table ?? t.name)); + expect(unmanaged.filter((t: string) => !NOT_AN_OBJECT.includes(t))).toEqual([]); + } } describe('os migrate plan examines exactly what os serve registers, per app shape (#22506)', () => { @@ -159,7 +165,7 @@ describe('os migrate plan examines exactly what os serve registers, per app shap expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_app', 'sys_account', 'sys_automation_run'])); }, CASE_TIMEOUT_MS); - it('a host config — instances in plugins, declaring through the manifest service', () => { + it('a host config — instances in plugins beside its own metadata, the showcase\'s shape', () => { const dir = project({ 'objectstack.config.ts': [ 'class HostPlugin {', @@ -171,13 +177,17 @@ describe('os migrate plan examines exactly what os serve registers, per app shap ' });', ' }', '}', - 'export default { plugins: [new HostPlugin()] };', + 'export default {', + " manifest: { id: 'com.example.os22506parityhostapp', name: 'host app', version: '0.0.0', type: 'app' },", + " objects: [{ name: 'os22506_parity_hostapp', fields: { title: { type: 'text' } } }],", + ' plugins: [new HostPlugin()],', + '};', '', ].join('\n'), }); const r = bootThenPlan(dir); expectParity(r); - expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_host', 'sys_account'])); + expect(r.tables).toEqual(expect.arrayContaining(['os22506_parity_host', 'os22506_parity_hostapp', 'sys_account'])); }, CASE_TIMEOUT_MS); it('a standalone stack — a compiled artifact and no config', () => { From 5bdfba6bc1e74f7ef36bb09424594d7f64c7dfa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:42:32 +0000 Subject: [PATCH 6/6] fix(cli): os migrate unmapped-columns boots the plan's object set, served platform included Its documented contract is the plan's own unmapped_column findings over the plan's object set; with composeServedPlatform on plan/apply only, a platform object the plan reports on (sys_account) was OBJECT_NOT_FOUND here. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/22506-migrate-composes-boot.md | 6 ++-- .../unmapped-columns.integration.test.ts | 35 ++++++++++++++++++- .../src/commands/migrate/unmapped-columns.ts | 8 +++-- packages/cli/src/utils/schema-migrate.ts | 6 ++-- .../cli/src/utils/schema-migration-plugins.ts | 7 ++-- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.changeset/22506-migrate-composes-boot.md b/.changeset/22506-migrate-composes-boot.md index 211ef3442cf..997ce0a72cb 100644 --- a/.changeset/22506-migrate-composes-boot.md +++ b/.changeset/22506-migrate-composes-boot.md @@ -3,11 +3,11 @@ '@objectstack/driver-sql': patch --- -fix(cli): `os migrate plan` / `apply` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) +fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) `os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. -The two commands now also compose, each through the rule `os serve` itself reads: +The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: - the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; - the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; @@ -15,6 +15,8 @@ The two commands now also compose, each through the rule `os serve` itself reads - every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; - the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. +`os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. + Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. **What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. diff --git a/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts b/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts index 89559ccd4c2..e9d465a9cd8 100644 --- a/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts +++ b/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts @@ -30,7 +30,12 @@ * type): refused in both faces, exit 1, naming the column and the record * id, no record emitted; * 7. the door writes nothing: the schema and every row are byte-identical - * after it ran. + * after it ran; + * 8. [#22506] a PLATFORM object the plan declares — `sys_account`, behind + * `os serve`'s auth gate, carrying the retired `issuer` column — is + * resolved and read, the same column set the plan reports for its table, + * not refused as `OBJECT_NOT_FOUND`. The plan composes what `os serve` + * mounts around the stack, so this door has to boot that object set too. * * The runtime half, that the engine's data door never serves these columns, is * the read narrowing's own pin @@ -130,6 +135,13 @@ await raw.knex.raw('ALTER TABLE um_contact ADD COLUMN ${HASH_SHADOW} text'); await raw.knex('um_contact').update({ ${HASH_SHADOW}: 'shadow' }); await raw.knex.raw('ALTER TABLE um_blob ADD COLUMN legacy_bytes blob'); await raw.knex('um_blob').update({ legacy_bytes: Buffer.from([1, 2, 255]) }); +// [#22506] A platform table the auth family declares, in the shape a +// pre-retirement deployment left it: today's definition plus the retired +// \`issuer\` column, one account row carrying a value in it. +const { SysAccount } = await import('@objectstack/platform-objects/identity'); +await raw.initObjects([SysAccount]); +await raw.knex.raw('ALTER TABLE sys_account ADD COLUMN issuer text'); +await raw.knex('sys_account').insert({ id: 'acc_os22506', provider_id: 'credential', account_id: 'u1', user_id: 'u1', issuer: 'local:credential' }); await raw.disconnect(); process.stderr.write('[fixture] seeded\\n'); process.exit(0); @@ -185,6 +197,9 @@ function runCli(argv: string[]): Promise { NO_COLOR: '1', OS_ARTIFACT_PATH: join(dir, 'dist', 'objectstack.json'), OS_SECRET_KEY: '0e2e'.repeat(16), + // [#22506] Auth-enabled, as a serving deployment is: `os serve`'s auth + // gate composes the auth family when a secret resolves. + OS_AUTH_SECRET: 'os22506-unmapped-secret-at-least-32-chars', }), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -217,6 +232,7 @@ let unknownJson: Run; let cappedJson: Run; let bytesJson: Run; let bytesHuman: Run; +let platformJson: Run; beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-21573-')); @@ -241,6 +257,7 @@ beforeAll(async () => { cappedJson = await runCli(door('um_contact', '--max-records', '2', '--json')); bytesJson = await runCli(door('um_blob', '--json')); bytesHuman = await runCli(door('um_blob')); + platformJson = await runCli(door('sys_account', '--json')); after = readState(); planJson = await runCli(['migrate', 'plan', '--json']); }, HOOK_TIMEOUT_MS); @@ -329,3 +346,19 @@ describe('os migrate unmapped-columns: the other answers', () => { for (const out of [bytesJson.stdout, bytesHuman.stdout]) expect(out).not.toContain('"type":"Buffer"'); }); }); + +describe('os migrate unmapped-columns: a platform object the plan declares (#22506)', () => { + it('resolves sys_account on an auth-enabled stack and reads the column the plan reports, not OBJECT_NOT_FOUND', () => { + expect(platformJson.code, `${platformJson.stdout}\n${platformJson.stderr}`).toBe(0); + const doc = JSON.parse(platformJson.stdout); + expect(doc).toMatchObject({ object: 'sys_account', table: 'sys_account', count: 1 }); + expect(doc.records).toEqual([{ id: 'acc_os22506', values: { issuer: 'local:credential' } }]); + // ONE column set with the plan, on this platform table too. + const plan = JSON.parse(planJson.stdout); + const planned = (plan.changes as Array<{ kind: string; table: string; column: string; actual: string }>) + .filter((c) => c.kind === 'unmapped_column' && c.table === 'sys_account') + .map((c) => ({ column: c.column, actual: c.actual })); + expect(planned.map((c) => c.column)).toEqual(['issuer']); + expect(doc.columns).toEqual(planned); + }); +}); diff --git a/packages/cli/src/commands/migrate/unmapped-columns.ts b/packages/cli/src/commands/migrate/unmapped-columns.ts index 35c1a6c619a..6213fba8ba5 100644 --- a/packages/cli/src/commands/migrate/unmapped-columns.ts +++ b/packages/cli/src/commands/migrate/unmapped-columns.ts @@ -285,14 +285,18 @@ export default class MigrateUnmappedColumns extends Command { try { // The `os migrate plan` boot, so the differ runs over the plan's object // set: schema DDL deferred and no seed (`deferSchemaDdl`), no database - // file brought into existence (`readOnlyProbe`), and the deployment's - // own composition (`composeHostStack`). + // file brought into existence (`readOnlyProbe`), the deployment's own + // composition (`composeHostStack`), and what `os serve` mounts around it + // (`composeServedPlatform`, #22506) — or a platform object the plan + // reports an `unmapped_column` on, such as `sys_account`, is one this + // command cannot resolve. stack = await bootSchemaStack({ jsonOutput: flags.json, ...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}), deferSchemaDdl: true, readOnlyProbe: true, composeHostStack: true, + composeServedPlatform: true, }); } catch (error: any) { if (flags.json) { diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 6dbc18f2056..499110f730a 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -472,8 +472,10 @@ export async function bootSchemaStack( * mounts AROUND the stack, each piece for its declarations only: the auth * family behind its auth gate, the provider of every capability its * resolver mounts (the stack's `requires` and the always-on slate), and - * the REST API plugin. Set by `os migrate plan` and `os migrate apply`, and - * by nothing else — their subject is the deployment's whole object set. + * the REST API plugin. Set by `os migrate plan` and `os migrate apply`, + * whose subject is the deployment's whole object set, and by `os migrate + * unmapped-columns`, which reads the plan's own findings over that set — + * and by nothing else. * See `buildSchemaMigrationPlugins`'s `servedPlatform`. */ composeServedPlatform?: boolean; diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 3faa9f72fcf..7a9664a482f 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -68,8 +68,8 @@ import { * companion columns its boot provisions are in the schema view too. * - **`PlatformObjectsPlugin`**, when absent — `serve` step 5c, composed on * every served boot. - * - **What `os serve` mounts AROUND the stack — `os migrate plan` and `apply` - * only** (`servedPlatform`, #22506, {@link composeServedPlatform}): the auth + * - **What `os serve` mounts AROUND the stack — `os migrate plan`, `apply` and + * `unmapped-columns` only** (`servedPlatform`, #22506, {@link composeServedPlatform}): the auth * family behind `serve`'s auth gate, the provider of every capability its * resolver mounts (the stack's `requires` and the always-on slate), and the * REST API plugin — each through the rule `serve` reads, each for its @@ -1531,7 +1531,8 @@ export async function buildSchemaMigrationPlugins(opts: { * stack surfaced it. * * Set by `os migrate plan` and `os migrate apply`, whose subject is the - * deployment's whole object set. Unset (`os migrate + * deployment's whole object set, and by `os migrate unmapped-columns`, which + * reads the plan's own `unmapped_column` findings over that set. Unset (`os migrate * security-catalog-overlays`, which also boots NON-deferred under `--apply`): * a provider is composed only when a composed plugin hard-depends on it * (#21732), as before.