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
10 changes: 10 additions & 0 deletions .changeset/28048-residual-top-level-flow-registers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/objectql': patch
---

fix(objectql): a flow a composed stack declares only on its top level, beside package bodies that do not declare it, registers with the automation engine at boot

Clause-②: yes (widening)
Metadata-protocol: none

The engine's `manifest` service registers a multi-package stack's residual (top-level items no package body declares, ADR-0130 D4) objects and connectors under the stack's own `manifest.id`; it now registers the residual's flows the same way. The automation plugin's boot pull reads flows from the ObjectQL registry only, so a residual flow used to be listed by the metadata service and never registered with the engine: `getFlow` answered `null`, a run answered "not found", and its trigger never armed. Such a flow now registers and runs at boot. A flow a package body declares is still registered once, under that body; stacks whose top level repeats their bodies, and single-package stacks, are unchanged.
54 changes: 54 additions & 0 deletions packages/objectql/src/manifest-service-residual-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,57 @@ describe('ADR-0015 D7 — the manifest service registers the residual\'s connect
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', SERVICE_ID, '2.4.0']]);
});
});

/**
* [ADR-0130 D4] The residual's FLOWS reach the registry too: the automation
* plugin's boot pull reads `registry.listItems('flow')` alone, so a residual
* flow registered by the metadata door only was listed by `/meta` and never
* registered with the engine. The boot-and-run reading is pinned on a kernel
* in `service-automation` (`residual-top-level-flow-boot.test.ts`).
*/
describe('ADR-0130 D4 — the manifest service registers the residual\'s flows', () => {
const flow = (name = 'acme_escalate') => ({
name,
label: 'Escalate',
type: 'autolaunched',
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
const flowOwners = (ql: ObjectQL) =>
(ql.registry.listItems('flow') as Array<{ name?: string; _packageId?: string; _packageVersion?: string }>)
.map((f) => [f.name, f._packageId, f._packageVersion])
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));

it('registers a residual flow under the stack\'s manifest id and version, beside a body\'s own', async () => {
const residualEntry = flow();
const { kernel } = await boot(registering('app', [
residualStack(noteObj(), { flows: [residualEntry] }, { flows: [flow('acme_route')] }),
]));
expect(flowOwners(engineOf(kernel))).toEqual([
['acme_escalate', RELEASE_ID, '1.0.0'],
['acme_route', SERVICE_ID, '2.4.0'],
]);
// The artifact's own entry is left as it arrived.
expect((residualEntry as { _packageId?: string })._packageId).toBeUndefined();
});

it('registers a residual flow when the stack has no other residual item', async () => {
const { kernel } = await boot(registering('app', [payload({
manifest: { ...app, id: RELEASE_ID },
objects: [caseObj(), accountObj()],
flows: [flow()],
packages: [
{ manifest: { ...svc, objects: [caseObj()] } },
{ manifest: { ...app, objects: [accountObj()] } },
],
})]));
expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', RELEASE_ID, '1.0.0']]);
});

it('CONTROL: a top-level flow a body repeats is the body\'s, registered once', async () => {
const { kernel } = await boot(registering('app', [
residualStack(noteObj(), { flows: [flow()] }, { flows: [flow()] }),
]));
expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', SERVICE_ID, '2.4.0']]);
});
});
82 changes: 46 additions & 36 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,31 +154,38 @@ function unclaimedTopLevelObjects(
}

/**
* The CONNECTORS of a multi-package stack's residual, and the id they are owned
* by — the residual rule's answer read the way {@link unclaimedTopLevelObjects}
* reads it, for the one other residual type a boot-time reader takes from the
* registry alone.
* The items of ONE registry type in a multi-package stack's residual, and the
* id they are owned by — the residual rule's answer read the way
* {@link unclaimedTopLevelObjects} reads it, for the residual types a
* boot-time reader takes from the registry alone:
*
* Why the engine needs them (ADR-0015 D7): the connector credential lender and
* the ADR-0097 reconcile read `registry.listItems('connector')`, never the
* metadata service. A residual connector instance registered by the metadata
* door only was therefore invisible to both at boot — the instance did not
* materialize, and a datasource's `external.connector` borrow of it was refused
* as undeclared until a dev reload carried the artifact in — while the composed
* check (`composeStacks`) accepts that borrow, because the instance is declared.
* - `connector` (ADR-0015 D7): the connector credential lender and the
* ADR-0097 reconcile read `registry.listItems('connector')`, never the
* metadata service. A residual connector instance registered by the
* metadata door only was therefore invisible to both at boot — the instance
* did not materialize, and a datasource's `external.connector` borrow of it
* was refused as undeclared until a dev reload carried the artifact in —
* while the composed check (`composeStacks`) accepts that borrow, because
* the instance is declared.
* - `flow`: the automation plugin's boot pull (`service-automation`'s
* `start()`) reads `registry.listItems('flow')`, never the metadata service.
* A residual flow registered by the metadata door only was listed by `/meta`
* and never registered with the engine: `getFlow` answered `null`, a run
* answered "not found", and its trigger never armed.
*
* @returns `undefined` when the stack has no residual connector.
* @returns `undefined` when the stack has no residual item of `type`.
*/
function unclaimedTopLevelConnectors(
function unclaimedTopLevelItemsOfType(
stack: unknown,
): { ownerId: string | undefined; ownerVersion: string | undefined; connectors: Record<string, unknown>[] } | undefined {
type: 'connector' | 'flow',
): { type: 'connector' | 'flow'; ownerId: string | undefined; ownerVersion: string | undefined; items: Record<string, unknown>[] } | undefined {
const residual = unclaimedTopLevel(stack);
if (!residual) return undefined;
const connectors = residual.items
.filter((entry) => entry.type === 'connector')
const items = residual.items
.filter((entry) => entry.type === type)
.map((entry) => entry.item as Record<string, unknown>);
return connectors.length > 0
? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, connectors }
return items.length > 0
? { type, ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, items }
: undefined;
}

Expand Down Expand Up @@ -800,11 +807,14 @@ export class ObjectQLPlugin implements Plugin {
// no package body declares — as the residual rule answers it. See
// {@link unclaimedTopLevelObjects}.
const residual = unclaimedTopLevelObjects(artifact);
// …and its CONNECTORS, which the credential lender reads from the
// registry alone. See {@link unclaimedTopLevelConnectors}.
const residualConnectors = unclaimedTopLevelConnectors(artifact);

if (ordered.length === 0 && residual === undefined && residualConnectors === undefined) {
// …and its CONNECTORS and FLOWS, which the credential lender and the
// automation plugin's boot pull read from the registry alone. See
// {@link unclaimedTopLevelItemsOfType}.
const residualItems = (['connector', 'flow'] as const)
.map((type) => unclaimedTopLevelItemsOfType(artifact, type))
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);

if (ordered.length === 0 && residual === undefined && residualItems.length === 0) {
// An artifact that declared `packages: []` registers nothing. Said out
// loud rather than returning quietly: "the install did nothing" is not
// a state anyone should have to infer from an absence.
Expand Down Expand Up @@ -878,20 +888,20 @@ export class ObjectQLPlugin implements Plugin {
// bodies (a composed stack keeps one member's manifest) that body's
// record must not be replaced.
if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered);
// [ADR-0015 D7] The residual's connectors, the same way and in the same
// place: after every body, under the residual rule's id, through the
// registry verb a body's own connectors take (`registerApp`'s metadata
// collections). No body declares one of these names — that is what
// makes it residual — so none of them shadows a body's entry.
// Stamped as a residual object is: the version first, on a shallow
// copy, leaving the artifact's own entry as the registry leaves it.
if (residualConnectors) {
const { ownerId, ownerVersion, connectors } = residualConnectors;
for (const connector of connectors) {
// [ADR-0015 D7, ADR-0130 D4] The residual's connectors and flows, the
// same way and in the same place: after every body, under the residual
// rule's id, through the registry verb a body's own connectors and
// flows take (`registerApp`'s metadata collections). No body declares
// one of these names — that is what makes it residual — so none of
// them shadows a body's entry. Stamped as a residual object is: the
// version first, on a shallow copy, leaving the artifact's own entry as
// the registry leaves it.
for (const { type, ownerId, ownerVersion, items } of residualItems) {
for (const item of items) {
const stamped = ownerVersion === undefined
? { ...connector }
: applyProtection({ ...connector } as any, { packageId: ownerId, packageVersion: ownerVersion });
ql.registry.registerItem('connector', stamped, 'name' as any, ownerId);
? { ...item }
: applyProtection({ ...item } as any, { packageId: ownerId, packageVersion: ownerVersion });
ql.registry.registerItem(type, stamped, 'name' as any, ownerId);
}
}
// [#24472] The boot reported every same-name pair it found
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0130 D4] A flow a composed stack declares only on its TOP LEVEL, beside
* package bodies that do not declare it — the stack's residual — is registered
* with the automation engine at boot and runs, the way one a body declares does.
*
* ## The defect this pins
*
* `composeStacks([mixed, other], { manifest: 'preserve' })`, where `mixed` is an
* input that carries `packages[]` AND a top-level flow no body repeats, booted:
* the residual rule registered that flow into the metadata service only, and the
* engine's `manifest` service registered the residual's objects and connectors
* only. The automation plugin's boot pull reads `registry.listItems('flow')`, so
* the flow never reached the engine: `/meta` listed a flow `getFlow` answered
* `null` for, and its trigger never armed.
*
* ## What each pin is for
*
* - the residual flow is registered with the engine at boot, owned by the
* stack's own id (the owner the residual rule names), and runs;
* - a flow a package body declares is registered once, owned by that body,
* whether or not the top level repeats it;
* - CONTROL: a stack with no residual flow registers exactly the bodies' flows.
*
* The stack is booted through the `manifest` service, the way `AppPlugin` hands
* a config boot's stack to it: `{ ...bundle.manifest, ...bundle }`.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { composeStacks, defineStack } from '@objectstack/spec';

import { AutomationServicePlugin } from './plugin.js';
import type { AutomationEngine } from './engine.js';

const MIXED_ID = 'com.acme.bundle';
const BODY_ID = 'com.acme.orders';

const pluginManifest = (id: string) => ({
id,
namespace: id.split('.').pop(),
version: '1.0.0',
type: 'plugin',
name: id.split('.').pop(),
});

const flow = (name: string) => ({
name,
label: name,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

/**
* An input that carries `packages[]` and, on its top level only, `topLevel`
* flows — the mixed shape. `bodyFlows` lands on the package body.
*/
const mixed = (topLevel: unknown[] | undefined, bodyFlows: unknown[]) =>
defineStack(
{
manifest: pluginManifest(MIXED_ID),
...(topLevel ? { flows: topLevel } : {}),
packages: [{ manifest: { ...pluginManifest(BODY_ID), flows: bodyFlows } }],
} as never,
{ strict: false },
);

const other = () => defineStack({ manifest: pluginManifest('com.acme.other') } as never, { strict: false });

const composed = (topLevel: unknown[] | undefined, bodyFlows: unknown[]) =>
composeStacks([mixed(topLevel, bodyFlows), other()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;

/** What `AppPlugin.init` hands the `manifest` service. */
const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle });

const kernels: ObjectKernel[] = [];
afterEach(async () => {
for (const k of kernels.splice(0)) await k.shutdown().catch(() => {});
});

/** Boot the engine and the automation plugin; register `stack` through the `manifest` service at init. */
async function boot(stack: Record<string, unknown>) {
const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false });
kernels.push(kernel);
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await kernel.use({
name: 'test.driver.sqlite',
type: 'standard',
version: '1.0.0',
async init(ctx: PluginContext) {
ctx.registerService('driver.default', driver);
},
} as Plugin);
await kernel.use(new ObjectQLPlugin());
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
await kernel.use({
name: 'test.stack',
type: 'standard',
version: '1.0.0',
dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'],
async init(ctx: PluginContext) {
await ctx.getService<{ register(m: unknown): Promise<void> }>('manifest').register(payload(stack));
},
} as Plugin);
await kernel.bootstrap();
return { ql: kernel.getService<ObjectQL>('objectql'), automation: kernel.getService<AutomationEngine>('automation') };
}

const flowOwners = (ql: ObjectQL): Array<[unknown, unknown]> =>
(ql.registry.listItems('flow') as Array<{ name?: unknown; _packageId?: unknown }>)
.map((f): [unknown, unknown] => [f.name, f._packageId])
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));

describe('[ADR-0130 D4] a flow a mixed composed input declares only on its top level', () => {
it('is registered with the automation engine at boot, owned by the stack\'s own id, and runs', async () => {
const stack = composed([flow('acme_residual_flow')], [flow('acme_body_flow')]);
const { ql, automation } = await boot(stack);
const ownerId = (stack.manifest as { id?: string }).id;

expect(flowOwners(ql)).toEqual([
['acme_body_flow', BODY_ID],
['acme_residual_flow', ownerId],
]);
expect((await automation.listFlows()).sort()).toEqual(['acme_body_flow', 'acme_residual_flow']);
expect(await automation.getFlow('acme_residual_flow')).not.toBeNull();
const run = await automation.execute('acme_residual_flow');
expect(run.error).toBeUndefined();
expect(run.success).toBe(true);
});

it('a flow a package body declares is registered once, owned by that body, when the top level repeats it', async () => {
const { ql, automation } = await boot(composed([flow('acme_body_flow')], [flow('acme_body_flow')]));
expect(flowOwners(ql)).toEqual([['acme_body_flow', BODY_ID]]);
expect(await automation.listFlows()).toEqual(['acme_body_flow']);
});

it('CONTROL: a stack with no top-level flow registers exactly the bodies\' flows', async () => {
const { ql, automation } = await boot(composed(undefined, [flow('acme_body_flow')]));
expect(flowOwners(ql)).toEqual([['acme_body_flow', BODY_ID]]);
expect(await automation.listFlows()).toEqual(['acme_body_flow']);
});
});
Loading