|
| 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