Skip to content

Commit 5f31470

Browse files
fix(objectql): a connector a composed stack declares only on its top level, beside package bodies, lends its credential at boot (#28046)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiH18zL8aJRKfkWPzKCnyMP2)_ Fixes #28032 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 合成栈里,某个输入带 `packages[]` 包体、又只在顶层声明了一个连接器实例(没有包体声明同名)时,ObjectQL 的 `manifest` 服务现在会把这个"残留"连接器注册进 ObjectQL 注册表,归属栈自己的 `manifest.id`,和残留对象(ADR-0130 D4)已有的做法一样。 - **为什么改:** 凭据出借方和连接器 reconcile 只读 ObjectQL 注册表。实测修复前:构建期 `composeStacks(…, { manifest: 'preserve' })` 接受这个借用,开机时数据源借用却被拒,报"is not declared in this stack's connectors"。修复后开机即可借到。 - **风险与回滚:** 只多注册一种原本漏掉的残留项;包体重复声明的连接器、单栈、常规合成都不变。不改 spec,不改作者能写什么。回滚即 revert。 - **需要您做的:** 无(非治理面,合入走常规流程)。 ## What changes - **`packages/objectql/src/plugin.ts`.** A new `unclaimedTopLevelConnectors(stack)` reads the residual rule's own answer (`unclaimedTopLevel`, `@objectstack/metadata`) for `connector` items, as `unclaimedTopLevelObjects` does for objects. The `manifest` service registers them after every body and after the residual objects, under the residual rule's id, through `registry.registerItem('connector', …)`, the verb a body's connectors take. Each is stamped with the stack's version on a shallow copy, so the artifact's own entry is left as it arrived (the residual-object precedent). The "empty `packages: []`" warning no longer fires for a stack whose only residual is a connector. - **`packages/services/service-automation/src/connector-credential-residual-connector.test.ts`** (new). A real kernel (ObjectKernel, ObjectQLPlugin over SQLite, AutomationServicePlugin) boots `composeStacks([mixed(instance), looseBorrower], { manifest: 'preserve' })` through the `manifest` service and borrows through `CONNECTOR_CREDENTIAL_SOURCE_SERVICE`. Pins: the composed check accepts the shape; the residual instance lends at boot and is owned by the stack's id; a residual descriptor (no `provider`) is refused at boot as a descriptor (`declares no provider`), not as undeclared; CONTROL: an instance a package body declares lends, owned by the body. - **`packages/objectql/src/manifest-service-residual-objects.test.ts`.** Three engine-half pins: a residual connector registers under the stack's id and version beside a body's own; it registers when the stack has no residual object; CONTROL: a top-level connector a body repeats is the body's, registered once. - **`.changeset/28032-residual-connector-lends-at-boot.md`.** `@objectstack/objectql` patch. ## Why ### The measurement (before the fix, at `origin/main` 13d85bb) The new service-automation test, run against the unfixed `@objectstack/objectql` build: - `registry.listItems('connector')` was `[]` after boot, for the residual instance and the residual descriptor alike. - `source.borrow('sap_s4')` was refused: "connector instance 'sap_s4' (named by this datasource's external.connector) is not declared in this stack's connectors." - The CONTROL (the instance declared in a package body) lent, as did the composed check. So the card's premise held: the build accepts a borrow the boot refuses. The reload half ("lends after a reload") was read off `captureReloadedConnectors`, not measured. ### Why option (a) Option (a) makes the boot agree with what the composed check already accepts, and changes nothing an author can write. The residual rule already decides which items are residual and under which id, and the engine already registers that answer's objects; connectors are the second type a boot-time reader takes from the registry alone. Option (b) (refuse the borrow in `stack.zod.ts`) would narrow the accept set and was not claimed. ## Verification - `connector-credential-residual-connector.test.ts`: red before the fix (2 of 4: both residual cases), green after (4 of 4), against a rebuilt `@objectstack/objectql`. - `manifest-service-residual-objects.test.ts`: 12 passed. Reverse check from the committed fix: restoring `plugin.ts` from the parent commit fails exactly the two residual-connector pins; the CONTROL stays green. - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/service-automation typecheck`: exit 0. Full suites: `@objectstack/objectql` 493 files, 9595 passed; `@objectstack/service-automation` 224 files, 2845 passed (1 expected fail). - `dispatch-gates --commands` derived 63 families; all run with exit codes recorded (after building `./packages/**`). One red on the first pass: `check:slot-lookup` (an untyped `getService<any>('automation')` in the new test), fixed in `3ab79536`. `--ran`: 63 derived, 63 run, 0 NOT-MEASURED (a derived zero). `origin/main` merged at `a5d4ed3f` (5 commits; `cc5586da` touched the lender's `plugin.ts`); the derived list is unchanged after the merge, and the borrow and lender tests re-run green on the merge (21 passed). ## Acceptance notes - **Other residual types.** The `manifest` service still registers only the residual's objects and, now, connectors. Other registry-read collections a residual can carry (flows, permissions and the rest of `METADATA_ARRAY_KEYS`) are still metadata-service only. Not measured and not widened here; named in the os-dev-report for the PM. - **Both doors.** The test boots the config door's hand-off to the `manifest` service. The artifact door hands the engine the same stack through the same service, so the fix covers both; the artifact door was not booted separately. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran (`pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1831836 commit 5f31470

4 files changed

Lines changed: 297 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎packages/objectql/src/manifest-service-residual-objects.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,58 @@ describe('ADR-0130 D4 — a residual arriving after start()', () => {
277277
expect(bridged?._packageId).toBe(RELEASE_ID);
278278
});
279279
});
280+
281+
/**
282+
* [ADR-0015 D7] The residual's CONNECTORS reach the registry too: the
283+
* connector credential lender and the ADR-0097 reconcile read
284+
* `registry.listItems('connector')` alone, so a residual instance registered
285+
* by the metadata door only could not lend at boot, while the composed check
286+
* accepted a borrow of it. The borrow itself is pinned on a kernel in
287+
* `service-automation` (`connector-credential-residual-connector.test.ts`).
288+
*/
289+
describe('ADR-0015 D7 — the manifest service registers the residual\'s connectors', () => {
290+
const sap = (name = 'acme_sap') => ({
291+
name,
292+
label: 'SAP',
293+
type: 'api',
294+
provider: 'openapi',
295+
auth: { type: 'bearer', credentialRef: 'acme_sap_token' },
296+
});
297+
const connectorOwners = (ql: ObjectQL) =>
298+
(ql.registry.listItems('connector') as Array<{ name?: string; _packageId?: string; _packageVersion?: string }>)
299+
.map((c) => [c.name, c._packageId, c._packageVersion])
300+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
301+
302+
it('registers a residual connector under the stack\'s manifest id and version, beside a body\'s own', async () => {
303+
const residualEntry = sap();
304+
const { kernel } = await boot(registering('app', [
305+
residualStack(noteObj(), { connectors: [residualEntry] }, { connectors: [sap('acme_crm')] }),
306+
]));
307+
expect(connectorOwners(engineOf(kernel))).toEqual([
308+
['acme_crm', SERVICE_ID, '2.4.0'],
309+
['acme_sap', RELEASE_ID, '1.0.0'],
310+
]);
311+
// The artifact's own entry is left as it arrived.
312+
expect((residualEntry as { _packageId?: string })._packageId).toBeUndefined();
313+
});
314+
315+
it('registers a residual connector when the stack has no residual object', async () => {
316+
const { kernel } = await boot(registering('app', [payload({
317+
manifest: { ...app, id: RELEASE_ID },
318+
objects: [caseObj(), accountObj()],
319+
connectors: [sap()],
320+
packages: [
321+
{ manifest: { ...svc, objects: [caseObj()] } },
322+
{ manifest: { ...app, objects: [accountObj()] } },
323+
],
324+
})]));
325+
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', RELEASE_ID, '1.0.0']]);
326+
});
327+
328+
it('CONTROL: a top-level connector a body repeats is the body\'s, registered once', async () => {
329+
const { kernel } = await boot(registering('app', [
330+
residualStack(noteObj(), { connectors: [sap()] }, { connectors: [sap()] }),
331+
]));
332+
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', SERVICE_ID, '2.4.0']]);
333+
});
334+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,35 @@ function unclaimedTopLevelObjects(
153153
: undefined;
154154
}
155155

156+
/**
157+
* The CONNECTORS of a multi-package stack's residual, and the id they are owned
158+
* by — the residual rule's answer read the way {@link unclaimedTopLevelObjects}
159+
* reads it, for the one other residual type a boot-time reader takes from the
160+
* registry alone.
161+
*
162+
* Why the engine needs them (ADR-0015 D7): the connector credential lender and
163+
* the ADR-0097 reconcile read `registry.listItems('connector')`, never the
164+
* metadata service. A residual connector instance registered by the metadata
165+
* door only was therefore invisible to both at boot — the instance did not
166+
* materialize, and a datasource's `external.connector` borrow of it was refused
167+
* as undeclared until a dev reload carried the artifact in — while the composed
168+
* check (`composeStacks`) accepts that borrow, because the instance is declared.
169+
*
170+
* @returns `undefined` when the stack has no residual connector.
171+
*/
172+
function unclaimedTopLevelConnectors(
173+
stack: unknown,
174+
): { ownerId: string | undefined; ownerVersion: string | undefined; connectors: Record<string, unknown>[] } | undefined {
175+
const residual = unclaimedTopLevel(stack);
176+
if (!residual) return undefined;
177+
const connectors = residual.items
178+
.filter((entry) => entry.type === 'connector')
179+
.map((entry) => entry.item as Record<string, unknown>);
180+
return connectors.length > 0
181+
? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, connectors }
182+
: undefined;
183+
}
184+
156185
/**
157186
* Options for ObjectQLPlugin.
158187
*
@@ -771,8 +800,11 @@ export class ObjectQLPlugin implements Plugin {
771800
// no package body declares — as the residual rule answers it. See
772801
// {@link unclaimedTopLevelObjects}.
773802
const residual = unclaimedTopLevelObjects(artifact);
803+
// …and its CONNECTORS, which the credential lender reads from the
804+
// registry alone. See {@link unclaimedTopLevelConnectors}.
805+
const residualConnectors = unclaimedTopLevelConnectors(artifact);
774806

775-
if (ordered.length === 0 && residual === undefined) {
807+
if (ordered.length === 0 && residual === undefined && residualConnectors === undefined) {
776808
// An artifact that declared `packages: []` registers nothing. Said out
777809
// loud rather than returning quietly: "the install did nothing" is not
778810
// a state anyone should have to infer from an absence.
@@ -846,6 +878,22 @@ export class ObjectQLPlugin implements Plugin {
846878
// bodies (a composed stack keeps one member's manifest) that body's
847879
// record must not be replaced.
848880
if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered);
881+
// [ADR-0015 D7] The residual's connectors, the same way and in the same
882+
// place: after every body, under the residual rule's id, through the
883+
// registry verb a body's own connectors take (`registerApp`'s metadata
884+
// collections). No body declares one of these names — that is what
885+
// makes it residual — so none of them shadows a body's entry.
886+
// Stamped as a residual object is: the version first, on a shallow
887+
// copy, leaving the artifact's own entry as the registry leaves it.
888+
if (residualConnectors) {
889+
const { ownerId, ownerVersion, connectors } = residualConnectors;
890+
for (const connector of connectors) {
891+
const stamped = ownerVersion === undefined
892+
? { ...connector }
893+
: applyProtection({ ...connector } as any, { packageId: ownerId, packageVersion: ownerVersion });
894+
ql.registry.registerItem('connector', stamped, 'name' as any, ownerId);
895+
}
896+
}
849897
// [#24472] The boot reported every same-name pair it found
850898
// (`kernel:ready`, below); an artifact arriving later is judged once it
851899
// has registered, for the pairs naming one of its packages on either
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0015 D7 × ADR-0130 D4] A connector instance a composed stack declares
5+
* only on its TOP LEVEL, beside package bodies that do not declare it — the
6+
* stack's residual — lends its credential at boot, the way one a body
7+
* declares does.
8+
*
9+
* ## The defect this pins
10+
*
11+
* `composeStacks([mixed, borrower], { manifest: 'preserve' })`, where `mixed`
12+
* is an input that carries `packages[]` AND a top-level connector instance no
13+
* body repeats, is accepted at build: the composed check finds the instance
14+
* and judges that it can lend. At boot the residual rule registered that
15+
* instance into the metadata service only, and the engine's `manifest`
16+
* service registered the residual's OBJECTS only, so the lender — which reads
17+
* the ObjectQL registry (`readDeclaredConnectorItems`) — did not see it, and
18+
* the datasource's borrow was refused as undeclared until a dev reload
19+
* carried the artifact in. Build and boot gave two answers.
20+
*
21+
* ## What each pin is for
22+
*
23+
* - the residual instance lends at boot, under the stack's own id, which is
24+
* the owner the residual rule names;
25+
* - the residual descriptor (no `provider`) is refused at boot as a
26+
* descriptor, not as undeclared: the lender sees it and judges it;
27+
* - CONTROL: an instance a package body declares lends, as it always did.
28+
*
29+
* The stack is booted through the `manifest` service, the way `AppPlugin`
30+
* hands a config boot's stack to it: `{ ...bundle.manifest, ...bundle }`. The
31+
* artifact door hands the engine the same stack. The values are obvious dummies.
32+
*/
33+
import { afterEach, describe, expect, it } from 'vitest';
34+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
35+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { composeStacks, defineStack } from '@objectstack/spec';
38+
import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types';
39+
40+
import { AutomationServicePlugin } from './plugin.js';
41+
import type { AutomationEngine } from './engine.js';
42+
43+
const REF = 'DUMMY_RESIDUAL_SAP_TOKEN';
44+
const DUMMY_ENV_VALUE = 'dummy-residual-dddd-0004';
45+
46+
const MIXED_ID = 'com.acme.bundle';
47+
const BODY_ID = 'com.acme.orders';
48+
49+
const pluginManifest = (id: string) => ({
50+
id,
51+
namespace: id.split('.').pop(),
52+
version: '1.0.0',
53+
type: 'plugin',
54+
name: id.split('.').pop(),
55+
});
56+
57+
const instance = (over: Record<string, unknown> = {}) => ({
58+
name: 'sap_s4',
59+
label: 'SAP S/4HANA',
60+
type: 'api',
61+
provider: 'fake',
62+
providerConfig: {},
63+
auth: { type: 'bearer', credentialRef: REF },
64+
...over,
65+
});
66+
67+
const sapOrders = () => ({
68+
name: 'sap_orders',
69+
driver: 'odata',
70+
config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' },
71+
schemaMode: 'external',
72+
external: { connector: 'sap_s4' },
73+
});
74+
75+
/** The borrowing input, which declares no connector of its own. */
76+
const looseBorrower = () =>
77+
defineStack({ manifest: pluginManifest('com.acme.ds'), datasources: [sapOrders()] } as never, { strict: false });
78+
79+
/**
80+
* An input that carries `packages[]` and, on its top level only, `connectors`
81+
* — the mixed shape. `bodyConnectors` lands on the package body instead.
82+
*/
83+
const mixed = (topLevel: unknown[] | undefined, bodyConnectors?: unknown[]) =>
84+
defineStack(
85+
{
86+
manifest: pluginManifest(MIXED_ID),
87+
...(topLevel ? { connectors: topLevel } : {}),
88+
packages: [{ manifest: { ...pluginManifest(BODY_ID), ...(bodyConnectors ? { connectors: bodyConnectors } : {}) } }],
89+
} as never,
90+
{ strict: false },
91+
);
92+
93+
/** What `AppPlugin.init` hands the `manifest` service. */
94+
const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle });
95+
96+
const kernels: ObjectKernel[] = [];
97+
const envKeys: string[] = [];
98+
afterEach(async () => {
99+
for (const k of kernels.splice(0)) await k.shutdown().catch(() => {});
100+
for (const key of envKeys.splice(0)) delete process.env[key];
101+
});
102+
103+
function setEnv(key: string, value: string): void {
104+
envKeys.push(key);
105+
process.env[key] = value;
106+
}
107+
108+
/** Boot the engine and the automation plugin; register `stack` through the `manifest` service at init. */
109+
async function boot(stack: Record<string, unknown>) {
110+
const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false });
111+
kernels.push(kernel);
112+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
113+
await kernel.use({
114+
name: 'test.driver.sqlite',
115+
type: 'standard',
116+
version: '1.0.0',
117+
async init(ctx: PluginContext) {
118+
ctx.registerService('driver.default', driver);
119+
},
120+
} as Plugin);
121+
await kernel.use(new ObjectQLPlugin());
122+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory', credentialResolver: () => undefined }));
123+
await kernel.use({
124+
name: 'test.stack',
125+
type: 'standard',
126+
version: '1.0.0',
127+
dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'],
128+
async init(ctx: PluginContext) {
129+
ctx.getService<AutomationEngine>('automation').registerConnectorProvider('fake', (pctx: any) => ({
130+
def: { name: pctx.name, label: pctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] },
131+
handlers: { request: async () => ({ status: 200, ok: true, body: {} }) },
132+
}));
133+
await ctx.getService<{ register(m: unknown): Promise<void> }>('manifest').register(payload(stack));
134+
},
135+
} as Plugin);
136+
await kernel.bootstrap();
137+
const ql = kernel.getService<ObjectQL>('objectql');
138+
const source = kernel.getService<IConnectorCredentialSource>(CONNECTOR_CREDENTIAL_SOURCE_SERVICE);
139+
return { ql, source };
140+
}
141+
142+
const connectorOwners = (ql: ObjectQL): Array<[unknown, unknown]> =>
143+
(ql.registry.listItems('connector') as Array<{ name?: unknown; _packageId?: unknown }>)
144+
.map((c) => [c.name, c._packageId]);
145+
146+
describe('[ADR-0015 D7] a connector a mixed composed input declares only on its top level', () => {
147+
it('is accepted at build: the composed check judges that it can lend', () => {
148+
expect(() => composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never)).not.toThrow();
149+
});
150+
151+
it('lends its credential at boot, registered under the stack\'s own id', async () => {
152+
setEnv(REF, DUMMY_ENV_VALUE);
153+
const stack = composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;
154+
const { ql, source } = await boot(stack);
155+
const ownerId = (stack.manifest as { id?: string }).id;
156+
const handle = await source.borrow('sap_s4');
157+
expect(handle.connector).toBe('sap_s4');
158+
await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } });
159+
expect(connectorOwners(ql)).toEqual([['sap_s4', ownerId]]);
160+
});
161+
162+
it('is judged by the lender at boot when it cannot lend: a descriptor is refused as one, not as undeclared', async () => {
163+
// Hand-assembled: the composed check refuses a borrow of a descriptor.
164+
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };
165+
const stack = composeStacks([mixed([descriptor]), defineStack({ manifest: pluginManifest('com.acme.ds') } as never)] as never, {
166+
manifest: 'preserve',
167+
} as never) as Record<string, unknown>;
168+
const { ql, source } = await boot(stack);
169+
const refused = await source.borrow('sap_s4').then(() => undefined, (err: unknown) => err as { status?: number; message?: string });
170+
expect(refused?.status).toBe(422);
171+
expect(refused?.message).not.toContain('is not declared');
172+
expect(refused?.message).toContain('declares no provider');
173+
expect(connectorOwners(ql).map(([name]) => name)).toEqual(['sap_s4']);
174+
});
175+
176+
it('CONTROL: an instance a package body declares lends at boot, owned by that body', async () => {
177+
setEnv(REF, DUMMY_ENV_VALUE);
178+
const stack = composeStacks([mixed(undefined, [instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;
179+
const { ql, source } = await boot(stack);
180+
expect(connectorOwners(ql)).toEqual([['sap_s4', BODY_ID]]);
181+
await expect((await source.borrow('sap_s4')).get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } });
182+
});
183+
});

0 commit comments

Comments
 (0)