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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21732-migrate-plan-requires-providers.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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);
});
86 changes: 86 additions & 0 deletions packages/cli/src/utils/schema-migration-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading