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/28032-residual-connector-lends-at-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/objectql': patch
---

fix(objectql): a connector a composed stack declares only on its top level, beside package bodies that do not declare it, lends its credential at boot

Clause-②: no
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 under the stack's own `manifest.id`; it now registers the residual's connectors the same way. The connector credential lender and the ADR-0097 reconcile read the connectors from the ObjectQL registry only, so a residual connector instance used to be invisible to them at boot: the instance did not materialize, and a datasource whose `external.connector` names it had its borrow refused as undeclared until a dev reload, although `composeStacks(…, { manifest: 'preserve' })` accepts that borrow. Stacks whose top level repeats their bodies, and single-package stacks, are unchanged.
55 changes: 55 additions & 0 deletions packages/objectql/src/manifest-service-residual-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,58 @@ describe('ADR-0130 D4 — a residual arriving after start()', () => {
expect(bridged?._packageId).toBe(RELEASE_ID);
});
});

/**
* [ADR-0015 D7] The residual's CONNECTORS reach the registry too: the
* connector credential lender and the ADR-0097 reconcile read
* `registry.listItems('connector')` alone, so a residual instance registered
* by the metadata door only could not lend at boot, while the composed check
* accepted a borrow of it. The borrow itself is pinned on a kernel in
* `service-automation` (`connector-credential-residual-connector.test.ts`).
*/
describe('ADR-0015 D7 — the manifest service registers the residual\'s connectors', () => {
const sap = (name = 'acme_sap') => ({
name,
label: 'SAP',
type: 'api',
provider: 'openapi',
auth: { type: 'bearer', credentialRef: 'acme_sap_token' },
});
const connectorOwners = (ql: ObjectQL) =>
(ql.registry.listItems('connector') as Array<{ name?: string; _packageId?: string; _packageVersion?: string }>)
.map((c) => [c.name, c._packageId, c._packageVersion])
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));

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

it('registers a residual connector when the stack has no residual object', async () => {
const { kernel } = await boot(registering('app', [payload({
manifest: { ...app, id: RELEASE_ID },
objects: [caseObj(), accountObj()],
connectors: [sap()],
packages: [
{ manifest: { ...svc, objects: [caseObj()] } },
{ manifest: { ...app, objects: [accountObj()] } },
],
})]));
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', RELEASE_ID, '1.0.0']]);
});

it('CONTROL: a top-level connector a body repeats is the body\'s, registered once', async () => {
const { kernel } = await boot(registering('app', [
residualStack(noteObj(), { connectors: [sap()] }, { connectors: [sap()] }),
]));
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', SERVICE_ID, '2.4.0']]);
});
});
50 changes: 49 additions & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ function unclaimedTopLevelObjects(
: undefined;
}

/**
* 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.
*
* 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.
*
* @returns `undefined` when the stack has no residual connector.
*/
function unclaimedTopLevelConnectors(
stack: unknown,
): { ownerId: string | undefined; ownerVersion: string | undefined; connectors: Record<string, unknown>[] } | undefined {
const residual = unclaimedTopLevel(stack);
if (!residual) return undefined;
const connectors = residual.items
.filter((entry) => entry.type === 'connector')
.map((entry) => entry.item as Record<string, unknown>);
return connectors.length > 0
? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, connectors }
: undefined;
}

/**
* Options for ObjectQLPlugin.
*
Expand Down Expand Up @@ -771,8 +800,11 @@ 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) {
if (ordered.length === 0 && residual === undefined && residualConnectors === undefined) {
// 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 @@ -846,6 +878,22 @@ 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) {
const stamped = ownerVersion === undefined
? { ...connector }
: applyProtection({ ...connector } as any, { packageId: ownerId, packageVersion: ownerVersion });
ql.registry.registerItem('connector', stamped, 'name' as any, ownerId);
}
}
// [#24472] The boot reported every same-name pair it found
// (`kernel:ready`, below); an artifact arriving later is judged once it
// has registered, for the pairs naming one of its packages on either
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0015 D7 × ADR-0130 D4] A connector instance a composed stack declares
* only on its TOP LEVEL, beside package bodies that do not declare it — the
* stack's residual — lends its credential at boot, the way one a body
* declares does.
*
* ## The defect this pins
*
* `composeStacks([mixed, borrower], { manifest: 'preserve' })`, where `mixed`
* is an input that carries `packages[]` AND a top-level connector instance no
* body repeats, is accepted at build: the composed check finds the instance
* and judges that it can lend. At boot the residual rule registered that
* instance into the metadata service only, and the engine's `manifest`
* service registered the residual's OBJECTS only, so the lender — which reads
* the ObjectQL registry (`readDeclaredConnectorItems`) — did not see it, and
* the datasource's borrow was refused as undeclared until a dev reload
* carried the artifact in. Build and boot gave two answers.
*
* ## What each pin is for
*
* - the residual instance lends at boot, under the stack's own id, which is
* the owner the residual rule names;
* - the residual descriptor (no `provider`) is refused at boot as a
* descriptor, not as undeclared: the lender sees it and judges it;
* - CONTROL: an instance a package body declares lends, as it always did.
*
* The stack is booted through the `manifest` service, the way `AppPlugin`
* hands a config boot's stack to it: `{ ...bundle.manifest, ...bundle }`. The
* artifact door hands the engine the same stack. The values are obvious dummies.
*/
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 { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types';

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

const REF = 'DUMMY_RESIDUAL_SAP_TOKEN';
const DUMMY_ENV_VALUE = 'dummy-residual-dddd-0004';

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 instance = (over: Record<string, unknown> = {}) => ({
name: 'sap_s4',
label: 'SAP S/4HANA',
type: 'api',
provider: 'fake',
providerConfig: {},
auth: { type: 'bearer', credentialRef: REF },
...over,
});

const sapOrders = () => ({
name: 'sap_orders',
driver: 'odata',
config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' },
schemaMode: 'external',
external: { connector: 'sap_s4' },
});

/** The borrowing input, which declares no connector of its own. */
const looseBorrower = () =>
defineStack({ manifest: pluginManifest('com.acme.ds'), datasources: [sapOrders()] } as never, { strict: false });

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

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

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

function setEnv(key: string, value: string): void {
envKeys.push(key);
process.env[key] = value;
}

/** 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', credentialResolver: () => undefined }));
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) {
ctx.getService<AutomationEngine>('automation').registerConnectorProvider('fake', (pctx: any) => ({
def: { name: pctx.name, label: pctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] },
handlers: { request: async () => ({ status: 200, ok: true, body: {} }) },
}));
await ctx.getService<{ register(m: unknown): Promise<void> }>('manifest').register(payload(stack));
},
} as Plugin);
await kernel.bootstrap();
const ql = kernel.getService<ObjectQL>('objectql');
const source = kernel.getService<IConnectorCredentialSource>(CONNECTOR_CREDENTIAL_SOURCE_SERVICE);
return { ql, source };
}

const connectorOwners = (ql: ObjectQL): Array<[unknown, unknown]> =>
(ql.registry.listItems('connector') as Array<{ name?: unknown; _packageId?: unknown }>)
.map((c) => [c.name, c._packageId]);

describe('[ADR-0015 D7] a connector a mixed composed input declares only on its top level', () => {
it('is accepted at build: the composed check judges that it can lend', () => {
expect(() => composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never)).not.toThrow();
});

it('lends its credential at boot, registered under the stack\'s own id', async () => {
setEnv(REF, DUMMY_ENV_VALUE);
const stack = composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;
const { ql, source } = await boot(stack);
const ownerId = (stack.manifest as { id?: string }).id;
const handle = await source.borrow('sap_s4');
expect(handle.connector).toBe('sap_s4');
await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } });
expect(connectorOwners(ql)).toEqual([['sap_s4', ownerId]]);
});

it('is judged by the lender at boot when it cannot lend: a descriptor is refused as one, not as undeclared', async () => {
// Hand-assembled: the composed check refuses a borrow of a descriptor.
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };
const stack = composeStacks([mixed([descriptor]), defineStack({ manifest: pluginManifest('com.acme.ds') } as never)] as never, {
manifest: 'preserve',
} as never) as Record<string, unknown>;
const { ql, source } = await boot(stack);
const refused = await source.borrow('sap_s4').then(() => undefined, (err: unknown) => err as { status?: number; message?: string });
expect(refused?.status).toBe(422);
expect(refused?.message).not.toContain('is not declared');
expect(refused?.message).toContain('declares no provider');
expect(connectorOwners(ql).map(([name]) => name)).toEqual(['sap_s4']);
});

it('CONTROL: an instance a package body declares lends at boot, owned by that body', async () => {
setEnv(REF, DUMMY_ENV_VALUE);
const stack = composeStacks([mixed(undefined, [instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;
const { ql, source } = await boot(stack);
expect(connectorOwners(ql)).toEqual([['sap_s4', BODY_ID]]);
await expect((await source.borrow('sap_s4')).get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } });
});
});
Loading