Skip to content

Commit 55635fc

Browse files
huangyiireneclaude
andauthored
fix(service-automation): reconcile declarative connectors against a set the metadata reload actually refreshes (#7742) (#7847)
A connector edit followed by a metadata reload changed nothing: no reconcile, no teardown, no re-materialize — the pre-edit connector kept serving until the process restarted. `os dev` masks it (the serve child restarts on recompile), so the trigger that walks into it is a Studio package publish into a running server. Confirmed on origin/main before the fix: an edited, an added and a deleted connector are all no-ops on the reload path. The reconcile was fine; its INPUT was a boot snapshot. `reconcileDeclaredConnectors` read `ql.registry.listItems('connector')`, and no reload path re-ingests connector items into that registry — ObjectQL's own `metadata:reloaded` handler re-ingests the payload's OBJECT definitions and stops there. So the reconcile compared the boot world against itself and found nothing to do. Every existing test drove the reload through a hand-mutated fake registry, which is why the path looked covered. `readDeclaredConnectorItems` now folds the sources a reload does refresh over that registry read, one per trigger: * the artifact carried on the `metadata:reloaded` payload — the dev/HMR trigger, and the only place an edited or deleted definition exists. Held as plugin state, so a degraded-instance retry firing minutes later does not fall back to the boot snapshot and rebuild the pre-edit instance. The fold is a replacement scoped to the packages the artifact speaks for (its manifest id + the `_packageId` stamped on its items), not a union: a union can never observe a deletion, while an unscoped replacement would tear down a connector another package contributed. * `protocol.getMetaItems({ type: 'connector' })` — the flattened `/meta` view the flow re-sync already reads, which layers the `sys_metadata` rows a publish promotes to active over the registry (overlay wins). Post-boot reconciles only: at boot the registry was just built and is current by construction, and that read costs a `sys_metadata` query and can fail — neither belongs on the fail-loudly boot path. Both reads fail safe. An absent, failing, or empty-while-the-registry-is-not answer is "no answer" and never tears down a live connector, and an announcement with no connector collection at all (a publish's bare `{ changed }`, or an artifact with no `connectors:` key) leaves every instance alone — only an artifact carrying an EMPTY array is the honest "none left". An unchanged entry still hashes to the same signature, so reloads do not churn live connections. The descriptor audit beside the reconcile reads the same declaration, so its warning describes the stack as it is now. `readFlowDefsFromProtocol`'s body is now the shared `readMetaItemsFromProtocol` — same normalization, same `null`-means-no-answer contract, byte-identical log message for the flow type. New tests use a REAL `SchemaRegistry` and deliberately never mutate it across the reload, which is the fact the old harness hid. Reverse-verified: with the fix reverted, the edit / add / delete / publish cases fail exactly the way the QA run observed, while the three never-tear-down guards pass on both sides. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3553298 commit 55635fc

3 files changed

Lines changed: 570 additions & 30 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): a metadata reload now reconciles declarative connectors instead of no-op'ing against a stale registry (#7742)
6+
7+
Editing a declarative provider-bound `connectors:` entry and reloading metadata
8+
changed nothing: no teardown, no re-materialize, the pre-edit connector kept
9+
serving until the process restarted. `os dev` masked it — it restarts the serve
10+
child on recompile — but a **Studio package publish** into a running server
11+
walked straight into it.
12+
13+
The reconcile's INPUT was the problem, not the reconcile. It read
14+
`ql.registry.listItems('connector')`, which is a BOOT snapshot: the artifact
15+
reload re-ingests OBJECT definitions into that registry (ObjectQL's own
16+
`metadata:reloaded` handler) and nothing re-ingests connector items, so the
17+
reconcile compared the boot world against itself and found nothing to do. Every
18+
existing test drove the reload through a hand-mutated fake registry, which is
19+
why it looked covered.
20+
21+
The reconcile (and the descriptor audit beside it) now reads the declaration
22+
from the sources a reload actually refreshes, folded over that registry read:
23+
24+
- the **artifact carried on the `metadata:reloaded` payload** — the dev/HMR
25+
reload trigger, and the only place an edited or deleted connector definition
26+
exists. The fold is scoped to the packages the artifact speaks for, so a
27+
connector contributed by an unrelated plugin package survives a reload, while
28+
one deleted from the reloaded stack is torn down;
29+
- **`protocol.getMetaItems({ type: 'connector' })`** — the flattened `/meta`
30+
view the flow re-sync already reads, which layers the `sys_metadata` rows a
31+
Studio publish promotes to active over the registry. Consulted on post-boot
32+
reconciles only; boot keeps its registry read, whose snapshot is current by
33+
construction.
34+
35+
Both reads fail safe: an absent, failing, or empty answer is treated as "no
36+
answer" and never tears down a live connector, and an announcement carrying no
37+
connector collection at all (a publish's bare `{ changed }`) leaves every
38+
instance alone. An unchanged entry still hashes to the same signature and is
39+
left untouched, so reloads do not churn live connections.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #7742 — the `metadata:reloaded` connector reconcile must read a set the
4+
// reload actually refreshed.
5+
//
6+
// Every other connector reconcile test (./connector-materialization.test.ts)
7+
// drives the reload through a HAND-MUTATED fake registry: the harness swaps the
8+
// array `listItems('connector')` returns and only then fires the hook, so the
9+
// reconcile always sees the new definition. Nothing on the real reload path
10+
// does that swap. `MetadataPlugin._reloadAndAnnounce` re-ingests the artifact
11+
// into the MetadataManager and announces `metadata:reloaded`; ObjectQL's own
12+
// handler re-ingests the payload's OBJECT definitions into the SchemaRegistry,
13+
// and nothing re-ingests its `connector` items. So on the real path the
14+
// registry the reconcile reads still holds the BOOT snapshot, and a
15+
// connector-only edit reconciles to a no-op: no teardown, no re-materialize,
16+
// the pre-edit connector keeps serving.
17+
//
18+
// These tests therefore use a REAL `SchemaRegistry` and deliberately never
19+
// mutate it across the reload — the registry going stale is the fact under
20+
// test, not an oversight — and assert on the OBSERVABLE effect of the
21+
// reconcile: the old instance's `close()` and a re-materialization carrying the
22+
// new `providerConfig`.
23+
24+
import { describe, it, expect } from 'vitest';
25+
import { LiteKernel } from '@objectstack/core';
26+
import { SchemaRegistry } from '@objectstack/objectql';
27+
import type {
28+
Connector,
29+
ConnectorProviderContext,
30+
ConnectorProviderFactory,
31+
} from '@objectstack/spec/integration';
32+
import { AutomationServicePlugin } from './plugin.js';
33+
import type { AutomationEngine } from './engine.js';
34+
35+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
36+
37+
const PKG = 'com.acme.billing';
38+
39+
/** A provider-bound declarative connector entry — the shape an artifact carries. */
40+
function providerConnector(name: string, endpoint: string) {
41+
return {
42+
name,
43+
label: name,
44+
type: 'api',
45+
provider: 'fake',
46+
providerConfig: { endpoint },
47+
};
48+
}
49+
50+
/**
51+
* A provider factory that records each materialization's `providerConfig` and
52+
* each teardown, so a test can prove a re-materialization happened AND that it
53+
* carried the edited definition (rather than re-running the boot one).
54+
*/
55+
function makeRecordingProvider() {
56+
const materialized: Array<{ name: string; endpoint: unknown }> = [];
57+
const closed: string[] = [];
58+
const factory: ConnectorProviderFactory = (ctx: ConnectorProviderContext) => {
59+
materialized.push({
60+
name: ctx.name,
61+
endpoint: (ctx.providerConfig as Record<string, unknown> | undefined)?.endpoint,
62+
});
63+
return {
64+
def: {
65+
name: ctx.name,
66+
label: ctx.label,
67+
type: 'api',
68+
authentication: { type: 'none' },
69+
actions: [{ key: 'ping', label: 'Ping' }],
70+
} as unknown as Connector,
71+
handlers: { ping: async () => ({ ok: true }) },
72+
close: async () => { closed.push(ctx.name); },
73+
};
74+
};
75+
return { factory, materialized, closed };
76+
}
77+
78+
/**
79+
* Boot the automation plugin over a REAL `SchemaRegistry` holding the boot-time
80+
* connector items, and expose `reloadArtifact(...)` — which fires exactly what
81+
* `MetadataPlugin._reloadAndAnnounce` fires (`{ changed, metadata }`, the
82+
* freshly parsed artifact) and, like the real path, leaves the registry alone.
83+
*/
84+
async function bootWithRealRegistry(
85+
bootConnectors: unknown[],
86+
opts: { served?: () => unknown[] | undefined; packageOf?: (item: any) => string } = {},
87+
) {
88+
const registry = new SchemaRegistry({ multiTenant: false } as never);
89+
for (const item of bootConnectors) {
90+
registry.registerItem('connector', item, 'name' as never, opts.packageOf?.(item) ?? PKG);
91+
}
92+
const { factory, materialized, closed } = makeRecordingProvider();
93+
94+
let captured: any;
95+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
96+
kernel.use(new AutomationServicePlugin());
97+
kernel.use({
98+
name: 'test.harness',
99+
type: 'standard' as const,
100+
version: '1.0.0',
101+
dependencies: ['com.objectstack.service-automation'],
102+
async init(ctx: any) {
103+
captured = ctx;
104+
ctx.registerService('objectql', { registry });
105+
if (opts.served) {
106+
// Stands in for the protocol's flattened `/meta/connector` view:
107+
// the registry read with the `sys_metadata` overlay rows a Studio
108+
// publish promoted to active layered over it.
109+
ctx.registerService('protocol', {
110+
getMetaItems: async ({ type }: { type: string }) => {
111+
if (type !== 'connector') return [];
112+
const served = opts.served!();
113+
// `undefined` stands for a read that FAILS (the
114+
// sys_metadata query throwing), not an empty view.
115+
if (served === undefined) throw new Error('sys_metadata unavailable');
116+
return served;
117+
},
118+
});
119+
}
120+
ctx.getService('automation').registerConnectorProvider('fake', factory);
121+
},
122+
async start() {},
123+
} as never);
124+
await kernel.bootstrap();
125+
await flush();
126+
127+
/** The dev artifact reload: MetadataPlugin's payload, registry left as-is. */
128+
const reloadArtifact = async (connectors: unknown[] | undefined) => {
129+
await captured.trigger('metadata:reloaded', {
130+
changed: ['connector/billing'],
131+
metadata: {
132+
manifest: { id: PKG, version: '1.0.0' },
133+
objects: [],
134+
...(connectors === undefined ? {} : { connectors }),
135+
},
136+
});
137+
await flush();
138+
};
139+
140+
return {
141+
kernel,
142+
registry,
143+
reloadArtifact,
144+
materialized,
145+
closed,
146+
engine: kernel.getService('automation') as AutomationEngine,
147+
trigger: async (payload?: unknown) => {
148+
await captured.trigger('metadata:reloaded', payload);
149+
await flush();
150+
},
151+
};
152+
}
153+
154+
describe('#7742 — connector reconcile reads a set the reload refreshed', () => {
155+
it('re-materializes an edited connector on a dev artifact reload that never touches the registry', async () => {
156+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')]);
157+
expect(h.materialized).toEqual([{ name: 'billing', endpoint: 'https://old.example' }]);
158+
159+
// The edit lands in the artifact — and ONLY there, exactly as on the
160+
// real reload path.
161+
await h.reloadArtifact([providerConnector('billing', 'https://new.example')]);
162+
163+
// Observable effect: the boot instance was torn down and a new one
164+
// materialized from the EDITED definition.
165+
expect(h.closed).toEqual(['billing']);
166+
expect(h.materialized).toEqual([
167+
{ name: 'billing', endpoint: 'https://old.example' },
168+
{ name: 'billing', endpoint: 'https://new.example' },
169+
]);
170+
// …and it is the live, dispatchable one.
171+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
172+
173+
await h.kernel.shutdown();
174+
});
175+
176+
it('leaves an unedited connector alone (no reconnect churn on every reload)', async () => {
177+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')]);
178+
179+
await h.reloadArtifact([providerConnector('billing', 'https://old.example')]);
180+
181+
expect(h.closed).toEqual([]);
182+
expect(h.materialized).toHaveLength(1);
183+
184+
await h.kernel.shutdown();
185+
});
186+
187+
it('tears down a connector the reloaded artifact no longer declares', async () => {
188+
const h = await bootWithRealRegistry([
189+
providerConnector('billing', 'https://old.example'),
190+
providerConnector('shipping', 'https://ship.example'),
191+
]);
192+
expect(h.materialized).toHaveLength(2);
193+
194+
// `shipping` deleted from the stack; the registry still lists it.
195+
await h.reloadArtifact([providerConnector('billing', 'https://old.example')]);
196+
197+
expect(h.closed).toEqual(['shipping']);
198+
expect(h.engine.getRegisteredConnectors()).not.toContain('shipping');
199+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
200+
201+
await h.kernel.shutdown();
202+
});
203+
204+
it('materializes a connector added by the reloaded artifact', async () => {
205+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')]);
206+
207+
await h.reloadArtifact([
208+
providerConnector('billing', 'https://old.example'),
209+
providerConnector('shipping', 'https://ship.example'),
210+
]);
211+
212+
expect(h.engine.getRegisteredConnectors()).toContain('shipping');
213+
expect(h.materialized).toEqual([
214+
{ name: 'billing', endpoint: 'https://old.example' },
215+
{ name: 'shipping', endpoint: 'https://ship.example' },
216+
]);
217+
218+
await h.kernel.shutdown();
219+
});
220+
221+
it('leaves another package’s connector alone when an app reloads', async () => {
222+
// The fold is scoped to what the reloaded artifact speaks for. A
223+
// connector contributed by a DIFFERENT package is absent from that
224+
// artifact for the obvious reason — it was never in it — and reading
225+
// that absence as a deletion would take an unrelated integration down
226+
// on every recompile.
227+
const h = await bootWithRealRegistry(
228+
[providerConnector('billing', 'https://old.example'), providerConnector('slack', 'https://slack.example')],
229+
{ packageOf: (item) => (item.name === 'slack' ? 'com.objectstack.connector-slack' : PKG) },
230+
);
231+
expect(h.materialized).toHaveLength(2);
232+
233+
await h.reloadArtifact([providerConnector('billing', 'https://new.example')]);
234+
235+
expect(h.closed).toEqual(['billing']); // only the edited one
236+
expect(h.engine.getRegisteredConnectors()).toContain('slack');
237+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
238+
239+
await h.kernel.shutdown();
240+
});
241+
242+
// The named PRODUCTION trigger. A Studio package publish promotes the
243+
// authored `sys_metadata` rows to active and announces `metadata:reloaded`
244+
// with `{ changed }` only — no artifact — so the payload fold above cannot
245+
// see the edit. What CAN is the protocol's flattened `/meta/connector` view:
246+
// the same registry read with those active overlay rows layered over it
247+
// (`mergePackageAwareOverlay` — a row wins over the registry entry it
248+
// shadows). The reconcile reads that view on every post-boot run.
249+
describe('Studio package publish (no artifact on the payload)', () => {
250+
it('re-materializes off the published view while the registry stays stale', async () => {
251+
let served: unknown[] = [providerConnector('billing', 'https://old.example')];
252+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')], {
253+
served: () => served,
254+
});
255+
expect(h.materialized).toHaveLength(1);
256+
257+
// The publish: the overlay row now carries the edited definition,
258+
// and the registry — as in a real scoped kernel — does not.
259+
served = [providerConnector('billing', 'https://published.example')];
260+
await h.trigger({ changed: ['connector/billing'] });
261+
262+
expect(h.closed).toEqual(['billing']);
263+
expect(h.materialized[1]).toEqual({ name: 'billing', endpoint: 'https://published.example' });
264+
265+
await h.kernel.shutdown();
266+
});
267+
268+
it('an empty or failing published view never tears down live connectors', async () => {
269+
let served: unknown[] | undefined = [providerConnector('billing', 'https://old.example')];
270+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')], {
271+
served: () => served,
272+
});
273+
274+
// Empty answer while the registry still lists the connector: the
275+
// view is a superset of the registry by construction, so this is
276+
// "not served the way we assume", not "the stack declares none".
277+
served = [];
278+
await h.trigger({ changed: ['object/account'] });
279+
expect(h.closed).toEqual([]);
280+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
281+
282+
// Same for a read that throws.
283+
served = undefined;
284+
await h.trigger({ changed: ['object/account'] });
285+
expect(h.closed).toEqual([]);
286+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
287+
288+
await h.kernel.shutdown();
289+
});
290+
});
291+
292+
it('a payload carrying no connector collection changes nothing', async () => {
293+
const h = await bootWithRealRegistry([providerConnector('billing', 'https://old.example')]);
294+
295+
// A reload whose artifact has no `connectors:` key at all, and a bare
296+
// announce with no payload (the Studio publish shape): neither may be
297+
// read as "the stack declares no connectors" and tear the live one down.
298+
await h.reloadArtifact(undefined);
299+
await h.trigger({ changed: ['object/account'] });
300+
await h.trigger();
301+
302+
expect(h.closed).toEqual([]);
303+
expect(h.engine.getRegisteredConnectors()).toContain('billing');
304+
305+
await h.kernel.shutdown();
306+
});
307+
});

0 commit comments

Comments
 (0)