diff --git a/.changeset/21732-migrate-plan-requires-providers.md b/.changeset/21732-migrate-plan-requires-providers.md new file mode 100644 index 00000000000..7919b2a4bb4 --- /dev/null +++ b/.changeset/21732-migrate-plan-requires-providers.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +`os migrate plan` and `os migrate apply` boot a config whose connector plugins depend on a service that only `requires` supplies (#21732). Before this fix, both commands exited 1 on a fresh `create-objectstack -t blank` app and on `examples/app-showcase` with `[Kernel] Dependency 'com.objectstack.service-automation' not found for plugin 'com.objectstack.connector.rest'`. + +Clause-②: no + +- **Why it failed.** The connectors (`@objectstack/connector-rest`, `-openapi`, `-mcp`, `-slack`) declare a hard dependency on the automation service. The blank template and the showcase ask for automation only through `requires: ['automation', …]`. `os serve` turns that token into the provider, but the schema-migration composition read `config.plugins` and never read `requires`. +- **What it composes now.** It uses the same token lookup `os serve` uses (`Serve.CAPABILITY_PROVIDERS`, with exact identity matching, and an explicit instance in `plugins` still wins). It composes a provider only when a plugin it already composed hard-depends on that provider and the config's `requires` (or the always-on slate) supplies it. +- **Automation is taken inert** (`armRuntime: false`). The engine and node registry come up. No flow is registered, no trigger or job is bound, no connector is materialized and no suspended run is resumed. Its `init()` declares `sys_automation_run`, `sys_flow_dispatch` and `sys_flow_credential`, so the plan now covers the tables `os serve` creates for this capability. +- **A provider with no measured declaration posture is refused by name.** The refusal names the plugin, its dependency and the token, instead of booting that provider's `start()` inside a dry run. A dependency that no token supplies is still refused by the kernel, as `os serve` refuses it. +- A config that lists no plugin with such a dependency composes exactly what it did before. diff --git a/packages/cli/src/utils/schema-migrate.requires-providers.integration.test.ts b/packages/cli/src/utils/schema-migrate.requires-providers.integration.test.ts new file mode 100644 index 00000000000..7dd13befd7c --- /dev/null +++ b/packages/cli/src/utils/schema-migrate.requires-providers.integration.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootSchemaStack } from './schema-migrate.js'; + +/** + * #21732 — `os migrate plan` / `apply` boot a config whose plugins hard-depend + * on a service that only `requires` supplies. + * + * Every connector (`connector-rest`, `-openapi`, `-mcp`, `-slack`) declares + * `dependencies = ['com.objectstack.service-automation']`, and the blank + * template and the showcase ask for automation only through + * `requires: ['automation', …]`. `os serve` resolves that token to the + * provider; the schema-migration composition did not, so the kernel refused to + * order the boot — `Dependency 'com.objectstack.service-automation' not found + * for plugin 'com.objectstack.connector.rest'` — and both commands exited 1 on + * every new blank app. + * + * The fixture is that shape with the connector reduced to what the kernel + * reads: a name, the hard dependency, and an `init()` that registers a + * provider factory on the `automation` service, as the real connectors do. + * Pinned over the REAL kernel boot, because the defect is an ordering refusal + * no composition-level assertion can stand in for; the composition itself is + * pinned in `schema-migration-plugins.test.ts`. + */ +describe('os migrate plan/apply resolve the requires-supplied provider a plugin depends on (#21732)', () => { + let dir: string; + const savedEnv: Record = {}; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-21732-')); + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + 'class DependentConnector {', + " name = 'com.example.os21732.connector';", + " dependencies = ['com.objectstack.service-automation'];", + ' async init(ctx: any) {', + " ctx.getService('automation').registerConnectorProvider('os21732', () => ({}));", + ' }', + " async start() { throw new Error('a host start() must not run under os migrate'); }", + '}', + 'export default {', + " manifest: { id: 'com.example.os21732', name: 'requires-supplied provider', version: '0.0.0', type: 'app' },", + " requires: ['automation'],", + " objects: [{ name: 'os21732_thing', fields: { title: { type: 'text' } } }],", + // A flow the inert engine must NOT register — registration is what + // arms its trigger. + " flows: [{ name: 'os21732_flow', label: 'Flow', type: 'autolaunched',", + " nodes: [{ id: 'start', type: 'start', label: 'Start' }], edges: [] }],", + ' plugins: [new DependentConnector()],', + '};', + '', + ].join('\n'), + ); + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + // Artifact-less on purpose: the config is the only host. + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + }); + + afterAll(() => { + if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH; + else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('boots, orders the dependent plugin after the provider, and arms nothing', async () => { + const boot = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${join(dir, 'plan.db')}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + try { + const automation = boot.kernel.getService('automation'); + // The dependent plugin's init() ran AFTER the provider's — the ordering + // the kernel refused to produce before this card. + expect(automation.getConnectorProvider('os21732')).toBeTypeOf('function'); + // Inert: the config's flow is never registered, so no trigger is bound. + expect(await automation.listFlows()).toEqual([]); + // The provider's own tables are in the plan, as `os serve` creates them. + const objects = boot.allObjects().map((o: any) => o?.name); + expect(objects).toEqual(expect.arrayContaining(['sys_automation_run', 'sys_flow_dispatch', 'os21732_thing'])); + expect(boot.composition.notes.join(' ')).toContain( + "Composed AutomationServicePlugin for `requires: ['automation']`", + ); + } finally { + await boot.shutdown(); + } + }, 60_000); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts index 35f5b0f745e..c59c74f20dd 100644 --- a/packages/cli/src/utils/schema-migration-plugins.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -285,6 +285,92 @@ describe('buildSchemaMigrationPlugins', () => { // default on a cold, shared box. }, 60_000); + /** A config whose plugins carry the connectors' hard-dependency shape (#21732). */ + function requiresConfig(opts: { requires: string[]; dependsOn: string; extraPlugin?: string }): string { + const dir = tempProject(); + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + 'class DependentPlugin {', + " name = 'com.example.os21732.connector';", + ` dependencies = [${JSON.stringify(opts.dependsOn)}];`, + ' async init() {}', + '}', + opts.extraPlugin ?? '', + `export default { requires: ${JSON.stringify(opts.requires)}, plugins: [`, + ` new DependentPlugin(),${opts.extraPlugin ? ' new ExplicitProvider(),' : ''}`, + '] };', + '', + ].join('\n'), + ); + return dir; + } + + it('composes the requires-supplied provider a host plugin hard-depends on, inert (#21732)', async () => { + // The blank template's and the showcase's shape: a connector in `plugins` + // declaring `dependencies = ['com.objectstack.service-automation']`, and + // `automation` asked for only through `requires`. Without the provider the + // kernel refuses to order the boot at all. + const dir = requiresConfig({ requires: ['automation', 'triggers'], dependsOn: 'com.objectstack.service-automation' }); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + + expect(out.hostConfigLoaded).toBe(true); + expect(out.plugins.map((p: any) => p?.name)).toEqual([ + WRITE_GUARD, + 'com.example.os21732.connector', + 'com.objectstack.platform-objects', + 'com.objectstack.service-automation', + ]); + const automation = out.plugins.at(-1) as any; + // `serve`'s provider, not a stand-in: the same class the token resolves to. + expect(automation.constructor.name).toBe('AutomationServicePlugin'); + // Inert — the engine comes up and nothing is armed — and anchored where + // `serve` anchors it. + expect(automation.options).toEqual({ armRuntime: false, packageRoot: dir }); + // Only the provider the kernel cannot order without: `triggers` is + // declared too and arms record/schedule triggers, so it is not composed. + expect(out.plugins.some((p: any) => p?.constructor?.name === 'RecordChangeTriggerPlugin')).toBe(false); + expect(out.notes.join(' ')).toContain( + "Composed AutomationServicePlugin for `requires: ['automation']` — 'com.example.os21732.connector' depends on it", + ); + }, 60_000); + + it('control: a declared requires token NOTHING depends on composes nothing (#21732)', async () => { + const dir = tempProject(); + writeFileSync(join(dir, 'objectstack.config.ts'), "export default { requires: ['automation'], plugins: [] };\n"); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.plugins.map((p: any) => p?.name)).toEqual([WRITE_GUARD, 'com.objectstack.platform-objects']); + }, 60_000); + + it('an explicit provider in plugins wins, as under serve — never a second instance (#21732)', async () => { + const dir = requiresConfig({ + requires: ['automation'], + dependsOn: 'com.objectstack.service-automation', + extraPlugin: "class ExplicitProvider { name = 'com.objectstack.service-automation'; async init() {} }", + }); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.plugins.filter((p: any) => p?.name === 'com.objectstack.service-automation')).toHaveLength(1); + expect(out.plugins.some((p: any) => p?.constructor?.name === 'AutomationServicePlugin')).toBe(false); + }, 60_000); + + it('a dependency no requires token supplies is left to the kernel, as under serve (#21732)', async () => { + // Without `automation` in `requires`, `os serve` mounts no provider and + // refuses this config the same way; this boot does not invent one. + const dir = requiresConfig({ requires: [], dependsOn: 'com.objectstack.service-automation' }); + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.plugins.some((p: any) => p?.name === 'com.objectstack.service-automation')).toBe(false); + }, 60_000); + + it('refuses, by name, a provider the lookup resolves but no declaration posture covers (#21732)', async () => { + // `job` is on the always-on slate `serve` appends, so the lookup finds its + // provider — and its `start()` schedules work, which a dry run must not + // boot with a posture nobody measured. + const dir = requiresConfig({ requires: [], dependsOn: 'com.objectstack.service.job' }); + await expect(buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir })).rejects.toThrow( + /'com\.example\.os21732\.connector' depends on 'com\.objectstack\.service\.job'.*no declaration posture for 'job'/, + ); + }, 60_000); + it('composes the config\'s app WITHOUT its onEnable, and the lifecycle names it (#21054)', async () => { // `examples/app-crm`'s shape: a stack with metadata, and a named // `onEnable` export beside it. The AppPlugin this composition builds is diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 890494177f5..1ad093cfbcd 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -55,6 +55,12 @@ import { stackDeclaresMetadata } from './stack-collections.js'; * 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 + * 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. * * ## Phase 1 only for host plugins — and why that is the contract, not a dodge * @@ -1246,6 +1252,153 @@ const NOTHING_COMPOSED: SchemaMigrationComposition = Object.freeze({ coverage: null, }) as SchemaMigrationComposition; +/** + * How a `requires`-supplied provider is constructed on a declaration boot + * (#21732), keyed by the capability token `Serve.CAPABILITY_PROVIDERS` names it + * under. A token with no row here is never booted by this composition. + * + * One row today, because one provider is a hard dependency of shipped plugins: + * every connector (`connector-rest`, `-openapi`, `-mcp`, `-slack`) declares + * `dependencies = ['com.objectstack.service-automation']`, and the kernel + * refuses to order a plugin whose dependency is absent. The automation + * service is taken INERT, the posture the data-migration arm already uses + * (`./data-migration-plugins.ts`): `armRuntime: false` brings the engine and + * its node registry up and then registers no flow, binds no trigger or job, + * materializes no connector and resumes no suspended run — a migration is not + * a second server. Its suspended-run store is left at the default on purpose: + * in inert mode that store is never attached (the start pass returns first), + * and the default is what makes its `init()` declare `sys_automation_run` / + * `sys_flow_dispatch` beside `sys_flow_credential` — the tables `os serve` + * creates for this capability, so the plan covers them. `packageRoot` mirrors + * `serve`'s own argument for this token. + * + * ⛔ A row is a measured posture, never a guess: a provider whose `start()` + * arms or writes cannot be added here without saying how this boot keeps it + * from doing so. + */ +const DECLARATION_PROVIDER_POSTURES: Readonly unknown>> = + Object.freeze({ + automation: ({ packageRoot }: { packageRoot: string }) => ({ armRuntime: false, packageRoot }), + }); + +/** One plugin's `name` and hard `dependencies`, read the way the kernel reads them. */ +function pluginName(p: unknown): string | undefined { + const name = (p as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' ? name : undefined; +} +function hardDependencies(p: unknown): string[] { + const deps = (p as { dependencies?: unknown } | null | undefined)?.dependencies; + return Array.isArray(deps) ? deps.filter((d): d is string => typeof d === 'string') : []; +} + +/** + * The `requires`-supplied providers a declaration boot must compose so the + * kernel can order what it already composed (#21732). + * + * ## The defect + * + * A config asks for a platform service through `requires: [...]`, and `os serve` + * turns each token into its provider plugin (`Serve.CAPABILITY_PROVIDERS`, + * `serve` step 5). This composition read `config.plugins` and never + * `requires`, so a host plugin with a HARD dependency on such a provider could + * not be ordered: `os migrate plan` / `apply` exited 1 with + * `Dependency 'com.objectstack.service-automation' not found for plugin + * 'com.objectstack.connector.rest'` on a fresh blank scaffold and on + * `examples/app-showcase`, both of which list connectors in `plugins` and + * `automation` in `requires`. + * + * ## What is resolved, and why only this + * + * The lookup is `serve`'s: the same token table, the same exact identity match + * ({@link Serve.providesCapability}), the same "an explicit instance in + * `plugins` wins" rule. What is narrower is WHICH tokens are booted — only a + * token whose provider is the missing hard dependency of something already + * composed. Composing every declared provider would boot the tier of services + * `serve` runs (email, storage, queue, approvals…) inside a dry run, each with + * its own `start()`; a dependency the kernel cannot order without is the one + * case where this boot cannot be correct without the provider. Iterated to a + * fixed point, so a provider's own hard dependencies are resolved the same way. + * + * The token set searched is the config's declared `requires` plus the + * always-on slate `serve` appends for every non-`minimal` preset + * (`Serve.ALWAYS_ON_CAPABILITIES`) — the tokens under which a served boot of + * this config would have mounted the provider. A dependency NO token supplies + * is left to the kernel, whose refusal is then the same one `os serve` gives + * for this config. + * + * A resolved token with no {@link DECLARATION_PROVIDER_POSTURES} row is + * REFUSED here, by name, rather than booted with a posture nobody measured: + * absence must be loud, and the refusal names the plugin, its dependency and + * the token, which the kernel's own message cannot. + */ +export async function resolveRequiredProviders(opts: { + requires: unknown; + composed: readonly unknown[]; + packageRoot: string; +}): Promise<{ plugins: unknown[]; notes: string[] }> { + // Nothing unordered, nothing to resolve — and `serve`'s module (the whole + // command) is not loaded for the configs that never needed it. + const composedNames = new Set(opts.composed.map(pluginName)); + if (!opts.composed.some((p) => hardDependencies(p).some((d) => !composedNames.has(d)))) { + return { plugins: [], notes: [] }; + } + const { default: Serve } = await import('../commands/serve.js'); + const declared = Array.isArray(opts.requires) + ? opts.requires.filter((t): t is string => typeof t === 'string') + : []; + const tokens = [...new Set([...declared, ...Serve.ALWAYS_ON_CAPABILITIES])]; + + const plugins: unknown[] = []; + const notes: string[] = []; + const all = (): unknown[] => [...opts.composed, ...plugins]; + + for (;;) { + const names = new Set(all().map(pluginName).filter((n): n is string => n !== undefined)); + let next: { owner: string; dependency: string; token: string } | undefined; + for (const p of all()) { + for (const dependency of hardDependencies(p)) { + if (names.has(dependency)) continue; + // A dependency NO token supplies is left to the kernel — `os serve` + // refuses this config the same way. + const token = tokens.find((t) => { + const spec = Serve.CAPABILITY_PROVIDERS[t]; + // `serve`'s explicit-wins rule: a provider already composed (under + // its class name, say) is never mounted a second time. + return spec !== undefined + && spec.identities.includes(dependency) + && !Serve.providesCapability(all(), spec.identities); + }); + if (token) { next = { owner: pluginName(p) ?? '(unnamed plugin)', dependency, token }; break; } + } + if (next) break; + } + if (!next) break; + const { owner, dependency, token } = next; + const spec = Serve.CAPABILITY_PROVIDERS[token]!; + + const posture = DECLARATION_PROVIDER_POSTURES[token]; + if (!posture) { + throw new Error( + `Plugin '${owner}' depends on '${dependency}', which \`requires: ['${token}']\` ` + + `supplies under \`os serve\`, but the schema-migration boot has no declaration posture for '${token}' ` + + '— composing it would run its start() inside a dry run. Add the plugin explicitly to `plugins`, ' + + `or report that '${token}' needs a declaration posture in the CLI's schema-migration composition.`, + ); + } + const mod = (await import(/* webpackIgnore: true */ spec.pkg)) as Record; + const Ctor = mod[spec.export] as (new (arg: unknown) => unknown) | undefined; + if (typeof Ctor !== 'function') { + throw new Error(`Capability "${token}": ${spec.pkg} did not export ${spec.export}`); + } + plugins.push(new Ctor(posture({ packageRoot: opts.packageRoot }))); + notes.push( + `Composed ${spec.export} for \`requires: ['${token}']\` — '${owner}' depends on it — ` + + 'in its declaration posture (engine up, nothing armed).', + ); + } + return { plugins, notes }; +} + /** * The plugins `os migrate plan` / `apply` compose on top of the standalone data * stack — see this module's header for what and why. @@ -1285,6 +1438,9 @@ export async function buildSchemaMigrationPlugins(opts: { const notes: string[] = []; let hostConfigLoaded = false; let hostConfigError: string | null = null; + // #21732 — the config's capability declarations, read off the same loaded + // config the plugins came from. See {@link resolveRequiredProviders}. + let loadedRequires: unknown; if (hostConfigPath) { try { @@ -1294,6 +1450,7 @@ export async function buildSchemaMigrationPlugins(opts: { const { loadConfig } = await import('./config.js'); const { config } = await loadConfig(hostConfigPath); + loadedRequires = config?.requires; const hostPlugins: unknown[] = Array.isArray(config?.plugins) ? config.plugins : []; for (const plugin of hostPlugins) { if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin, lifecycle)); @@ -1374,6 +1531,20 @@ export async function buildSchemaMigrationPlugins(opts: { notes.push('Composed PlatformObjectsPlugin (the platform floor `os serve` composes unconditionally).'); } + // #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) { + const resolved = await resolveRequiredProviders({ + requires: loadedRequires, + composed: [...opts.basePlugins, ...plugins], + packageRoot: path.dirname(hostConfigPath), + }); + plugins.push(...resolved.plugins); + notes.push(...resolved.notes); + } + return { plugins, hostConfigPath, hostConfigLoaded, hostConfigError, notes, coverage: null, writeGuard, lifecycle, };