diff --git a/.changeset/seed-datasets-multitenant-replay-union.md b/.changeset/seed-datasets-multitenant-replay-union.md new file mode 100644 index 000000000..5fb31260b --- /dev/null +++ b/.changeset/seed-datasets-multitenant-replay-union.md @@ -0,0 +1,34 @@ +--- +"@objectstack/runtime": patch +"@objectstack/cloud-connection": patch +--- + +fix(runtime,cloud-connection): multi-tenant seed replay covers every source, not just the first (#3453) + +In multi-tenant deployments (enterprise `@objectstack/organizations`) a brand-new org +gets its own private copy of demo data by replaying the kernel's `seed-datasets` list +on the `sys_organization` insert. That list is meant to hold the union of every seed +source — every config-declared app AND every marketplace package — but two framework +traps (the same pair #3444 fixed for seed-summary) shrank it to just the first source: + +- The standard `PluginContext` exposes `getService`/`registerService` but has NO + `.kernel` handle, so `(ctx as any).kernel?.getService('seed-datasets')` always read + `undefined`. Each source then saw "nothing registered" and overwrote the list with + only its own datasets instead of extending it. +- `registerService` throws on a duplicate name, so the second source's re-register was + swallowed by the surrounding try/catch — its datasets (and, for a config app, its + replayer) silently lost. + +Net effect: with two config apps, or a config app plus marketplace packages, a new org +replayed only the first app's seeds. + +The fix mirrors #3444's seed-summary hardening: `seed-datasets` is now a single shared +array, registered once and mutated in place by every source through a new +`mergeSeedDatasets` helper that reads via the context's own resolver first. AppPlugin's +per-org replayer reads that live list at invoke time instead of a captured snapshot, so +it replays the full union — including datasets merged after its closure was built — and +the replayer itself is registered once and reused by later config apps. + +Covered by seam-level unit tests (accumulation across app + marketplace sources; the +replayer reads the live union). True multi-tenant end-to-end coverage requires the +enterprise `@objectstack/organizations` plugin, which lives in the cloud repo. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 201feff98..f9470d928 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -320,6 +320,57 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch { /* banner summary is best-effort — never break the heal */ } }; + /** + * Merge `datasets` onto the SHARED `seed-datasets` service via the runtime's + * register-once-then-mutate helper (#3453), so THIS package's seeds accumulate + * alongside every config app's and every other package's rather than clobbering + * them — the per-org replayer (AppPlugin) replays the whole union on the next + * `sys_organization` insert. Resolved lazily through `@objectstack/runtime` and + * guarded exactly like {@link recordSeedSummary}: a runtime that predates the + * helper — or a test that mocks the module without it — falls back to an + * equivalent inline merge. Returns the post-merge total for the log line. + */ + private mergeSeedDatasetsIntoKernel = async (ctx: PluginContext, datasets: any[]): Promise => { + try { + const mod: any = await import('@objectstack/runtime'); + if (typeof mod?.mergeSeedDatasets === 'function') { + const list = mod.mergeSeedDatasets(ctx, datasets); + return Array.isArray(list) ? list.length : datasets.length; + } + } catch { /* fall through to the inline merge below */ } + return this.mergeSeedDatasetsInline(ctx, datasets); + }; + + /** + * Fallback for {@link mergeSeedDatasetsIntoKernel} when the runtime helper is + * unavailable (older build / mocked module). Same register-once-then-mutate: + * read the live list through the context's OWN resolver first — a standard + * PluginContext has no `.kernel`, which is precisely why the old + * `(ctx as any).kernel` read clobbered instead of accumulating — push in place, + * and register only when the service does not yet exist so a second source + * cannot trip the duplicate-register throw. Returns the post-merge total. + */ + private mergeSeedDatasetsInline = (ctx: PluginContext, datasets: any[]): number => { + const c = ctx as any; + const read = (): any[] | undefined => { + if (typeof c?.getService === 'function') { + try { const v = c.getService('seed-datasets'); if (Array.isArray(v)) return v; } catch { /* absent */ } + } + if (typeof c?.kernel?.getService === 'function') { + try { const v = c.kernel.getService('seed-datasets'); if (Array.isArray(v)) return v; } catch { /* absent */ } + } + return undefined; + }; + const current = read(); + const list: any[] = Array.isArray(current) ? current : []; + list.push(...datasets); + if (!Array.isArray(current)) { + if (typeof c?.kernel?.registerService === 'function') c.kernel.registerService('seed-datasets', list); + else if (typeof c?.registerService === 'function') c.registerService('seed-datasets', list); + } + return list.length; + }; + private handleInstall = async (c: any, ctx: PluginContext): Promise => { const userId = await this.requireAuthenticatedUser(c, ctx); if (!userId) { @@ -905,16 +956,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (datasets.length > 0) { try { - const kernel: any = (ctx as any).kernel; - let existing: any[] = []; - try { - const v = kernel?.getService?.('seed-datasets'); - if (Array.isArray(v)) existing = v; - } catch { /* unset */ } - const merged = [...existing, ...datasets]; - if (kernel?.registerService) kernel.registerService('seed-datasets', merged); - else (ctx as any).registerService?.('seed-datasets', merged); - ctx.logger?.info?.(`[MarketplaceInstallLocal] merged ${datasets.length} seed dataset(s) into kernel (total: ${merged.length})`); + const total = await this.mergeSeedDatasetsIntoKernel(ctx, datasets); + ctx.logger?.info?.(`[MarketplaceInstallLocal] merged ${datasets.length} seed dataset(s) into kernel (total: ${total})`); } catch (err: any) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to merge seed-datasets: ${err?.message ?? err}`); } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 184ebca29..9fd5a3cc7 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -5,6 +5,7 @@ import { assertProtocolCompat } from '@objectstack/metadata-core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; +import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; @@ -750,31 +751,29 @@ export class AppPlugin implements Plugin { // captures the SeedLoaderService closure and exposes a // narrow `(orgId) => Promise` surface. try { - const kernel: any = (ctx as any).kernel; - const existing = (() => { - try { return kernel?.getService?.('seed-datasets'); } catch { return undefined; } - })(); - const merged = Array.isArray(existing) - ? [...existing, ...normalizedDatasets] - : normalizedDatasets; - const registerSvc = (name: string, value: any) => { - if (kernel?.registerService) kernel.registerService(name, value); - else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService(name, value); - }; - registerSvc('seed-datasets', merged); + // #3453: append this app's datasets onto the SHARED `seed-datasets` + // array (register-once-then-mutate). Reading through the context's + // own resolver — NOT the non-existent `(ctx as any).kernel`, which was + // always undefined — means a SECOND config app (or a marketplace + // install) extends the list instead of clobbering it or tripping the + // duplicate-register throw. The per-org replayer below re-reads this + // live list on every call, so a new org replays the full union. + const sharedDatasets = mergeSeedDatasets(ctx, normalizedDatasets); - const metadataNow = ctx.getService('metadata') as IMetadataService | undefined; const loggerRef = ctx.logger; const replayer = async (organizationId: string) => { if (!organizationId) return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] }; - const md = metadataNow ?? (ctx.getService('metadata') as IMetadataService | undefined); + const md = ctx.getService('metadata') as IMetadataService | undefined; if (!md) { loggerRef.warn('[seed-replayer] metadata service unavailable'); return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] }; } - const datasetsNow = (() => { - try { return kernel?.getService?.('seed-datasets'); } catch { return merged; } - })() ?? merged; + // Read the LIVE shared list on every replay — NOT the + // `sharedDatasets` snapshot captured when this closure was built. + // An org created after a later app/marketplace install must still + // replay their seeds (the #3453 fix). Fall back to the snapshot + // only if the service somehow vanished. + const datasetsNow = readSeedDatasets(ctx) ?? sharedDatasets; if (!Array.isArray(datasetsNow) || datasetsNow.length === 0) { return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] }; } @@ -806,8 +805,15 @@ export class AppPlugin implements Plugin { errors: result.errors, }; }; - registerSvc('seed-replayer', replayer); - ctx.logger.info(`[Seeder] Registered ${normalizedDatasets.length} datasets + replayer on kernel (total seeds: ${merged.length})`); + // Register the replayer once; a later config app reuses the first + // one, which reads the now-extended shared list on every call — so + // there is no duplicate-register throw and no lost datasets (#3453). + const replayerRegistered = registerSeedReplayerOnce(ctx, replayer); + ctx.logger.info( + replayerRegistered + ? `[Seeder] Registered ${normalizedDatasets.length} datasets + replayer (total seeds: ${sharedDatasets.length})` + : `[Seeder] Appended ${normalizedDatasets.length} datasets to shared registry; reused existing replayer (total seeds: ${sharedDatasets.length})`, + ); } catch (e: any) { ctx.logger.warn('[Seeder] Failed to register seed-datasets/seed-replayer service', { error: e?.message }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 74c94f36e..458bc1aef 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -23,6 +23,10 @@ export { SeedLoaderService } from './seed-loader.js'; // contract shared by AppPlugin and the marketplace rehydrate/heal path. export { recordSeedOutcome } from './seed-summary.js'; export type { SeedSourceOutcome } from './seed-summary.js'; +// Multi-tenant seed-replay registry (#3453) — the register-once-then-mutate +// contract shared by AppPlugin and the marketplace install path so a new org +// replays the UNION of every seed source, not just the first one. +export { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; // External Datasource Federation — boot-validation gate (ADR-0015, Gate 2) export { ExternalValidationPlugin, createExternalValidationPlugin } from './external-validation-plugin.js'; export type { ExternalSchemaDriftEvent } from './external-validation-plugin.js'; diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts new file mode 100644 index 000000000..1c60c4300 --- /dev/null +++ b/packages/runtime/src/seed-datasets.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * #3453 — the multi-tenant seed-replay seam. + * + * In multi-tenant mode a new org replays the kernel's `seed-datasets` list on the + * `sys_organization` insert, so that list MUST hold the union of every seed source + * (every config app + every marketplace package). Two framework traps used to + * shrink it to just the first source — the same pair #3444 fixed for seed-summary: + * + * ① the standard PluginContext has NO `.kernel` handle, so the old + * `(ctx as any).kernel?.getService('seed-datasets')` read was always + * undefined and each source clobbered rather than extended the list; and + * ② `registerService` throws on a duplicate name, so the second source's + * re-register was swallowed and its datasets lost. + * + * Part A pins the register-once-then-mutate helper directly against a faithful + * kernel fake (getService throws on miss, registerService throws on dupe, no + * `.kernel` — mirrors seed-summary.test.ts). Part B boots the REAL AppPlugin and + * proves its per-org replayer replays the live union of two config apps plus a + * marketplace-style merge — not just whichever app registered first. + */ + +// ── Part B plumbing: capture what the replayer feeds the seed loader ────────── +let loadCalls: any[] = []; +let seedResult: any = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + +vi.mock('./seed-loader.js', () => ({ + SeedLoaderService: class { + constructor(..._args: any[]) { /* ql / metadata / logger ignored — mocked */ } + async load(request: any) { loadCalls.push(request); return seedResult; } + }, +})); +// Keep every other `@objectstack/spec/data` export real; only stub the schema so +// the datasets pass through verbatim and stay assert-able (the heal test does the +// same passthrough). +vi.mock('@objectstack/spec/data', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, SeedLoaderRequestSchema: { parse: (x: any) => x } }; +}); + +import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; +import { AppPlugin } from './app-plugin'; + +/** A faithful kernel context: getService throws on miss, registerService throws on + * dupe, and — crucially — NO `.kernel` property (mirrors packages/core/src/kernel.ts). */ +function makeKernelCtx() { + const services = new Map(); + return { + services, + getService: (n: string) => { + if (!services.has(n)) throw new Error(`[Kernel] Service '${n}' not found`); + return services.get(n); + }, + registerService: (n: string, v: unknown) => { + if (services.has(n)) throw new Error(`[Kernel] Service '${n}' already registered`); + services.set(n, v); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +} + +const ds = (object: string, id: string) => ({ object, records: [{ id }] }); + +/** + * A config-app bundle carrying a single seed dataset via the modern top-level + * `data` field. The `manifest: { id }` matters: AppPlugin collects seeds from BOTH + * top-level `data` AND `(bundle.manifest ?? bundle).data`, so a bundle whose + * manifest falls back to the bundle itself re-collects its own `data` array twice + * (a separate legacy quirk, #3434). Giving `manifest` a distinct object (with the + * id the constructor requires, but no `data`) keeps each app's contribution to + * exactly one dataset, so this seam test asserts on the cross-SOURCE union rather + * than incidental self-duplication. + */ +const appBundle = (id: string, dataset: { object: string; records: unknown[] }) => ({ + id, + manifest: { id }, + data: [dataset], +}); + +// ──────────────────────────────────────────────────────────────────────────── +describe('seed-datasets helper — register-once-then-mutate (#3453)', () => { + it('reads the live service through ctx.getService even though ctx has no .kernel (defect ①)', () => { + const ctx = makeKernelCtx(); + expect('kernel' in ctx).toBe(false); // the real PluginContext shape + + const list = mergeSeedDatasets(ctx, [ds('crm_account', 'a')]); + // Registered under the real service name, and the SAME reference is read + // back through the context's own resolver — the only channel that works. + expect(ctx.services.get('seed-datasets')).toBe(list); + expect(readSeedDatasets(ctx)).toBe(list); + expect(readSeedDatasets(ctx)).toEqual([ds('crm_account', 'a')]); + }); + + it('accumulates a second source instead of clobbering, with no duplicate-register throw (defects ①②)', () => { + const ctx = makeKernelCtx(); + const app = ds('crm_account', 'a'); + const market = ds('hot_lead', 'm'); + + mergeSeedDatasets(ctx, [app]); // config app registers the list + // The marketplace merge would blow up on `registerService('seed-datasets')` + // if it re-registered — it must mutate the existing list in place instead. + expect(() => mergeSeedDatasets(ctx, [market])).not.toThrow(); + + const stored = ctx.services.get('seed-datasets') as unknown[]; + expect(stored).toEqual([app, market]); // union, in source order + expect(readSeedDatasets(ctx)).toBe(stored); // same reference throughout + }); + + it('exposes the LIVE array so a reference captured early still grows (the replayer contract)', () => { + const ctx = makeKernelCtx(); + const captured = mergeSeedDatasets(ctx, [ds('a', '1')]); // e.g. a replayer closure holds this + mergeSeedDatasets(ctx, [ds('b', '2')]); // a later source appends + mergeSeedDatasets(ctx, [ds('c', '3')]); + + expect(readSeedDatasets(ctx)).toHaveLength(3); + expect(captured).toHaveLength(3); // the captured ref grew in place, no re-snapshot + }); + + it('registerSeedReplayerOnce registers once, then reuses — never re-registers (defect ②)', () => { + const ctx = makeKernelCtx(); + const first = () => 'first'; + const second = () => 'second'; + + expect(registerSeedReplayerOnce(ctx, first)).toBe(true); + expect(registerSeedReplayerOnce(ctx, second)).toBe(false); // reused, no throw + expect(ctx.services.get('seed-replayer')).toBe(first); // first wins + }); + + it('never throws when the context exposes no service methods', () => { + expect(() => mergeSeedDatasets({}, [ds('x', '1')])).not.toThrow(); + expect(readSeedDatasets({})).toBeUndefined(); + expect(readSeedDatasets(undefined)).toBeUndefined(); + expect(registerSeedReplayerOnce({}, () => 0)).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () => { + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + + beforeEach(() => { + loadCalls = []; + seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + process.env.OS_MULTI_ORG_ENABLED = 'true'; // per-org replay path; skip inline seed + }); + afterEach(() => { + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + vi.restoreAllMocks(); + }); + + /** Boot-tolerant kernel ctx: returns undefined for unknown services so + * AppPlugin.start() survives, but registerService still throws on dupe (so the + * register-once guards are exercised for real) and there is no `.kernel`. */ + function makeBootCtx() { + const services = new Map(); + services.set('objectql', { insert: vi.fn(async () => undefined) }); + services.set('metadata', {}); // truthy; all start() uses are `typeof x.method`-guarded + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getService: (n: string) => (services.has(n) ? services.get(n) : undefined), + registerService: (n: string, v: any) => { + if (services.has(n)) throw new Error(`[Kernel] Service '${n}' already registered`); + services.set(n, v); + }, + getServices: () => new Map(services), + hook: vi.fn(), + trigger: vi.fn(), + }; + return { ctx, services }; + } + + it('replays every config app AND a marketplace merge — not just the first source', async () => { + const { ctx, services } = makeBootCtx(); + + // Two real config apps boot against the SAME kernel context. + await new AppPlugin(appBundle('config-app-a', ds('crm_account', 'a1'))).start(ctx); + // The second app must boot without a duplicate-register throw and EXTEND the + // shared list rather than replace it. + await expect( + new AppPlugin(appBundle('config-app-b', ds('crm_contact', 'b1'))).start(ctx), + ).resolves.toBeUndefined(); + + // A marketplace package installs at runtime and merges its seeds the same + // way (cloud-connection calls this very helper via @objectstack/runtime). + mergeSeedDatasets(ctx, [ds('hot_lead', 'm1')]); + + // Exactly ONE replayer is registered (app A's; app B reused it). + const replayer = services.get('seed-replayer'); + expect(typeof replayer).toBe('function'); + + // A brand-new org is created → the middleware invokes the replayer. + seedResult = { summary: { totalInserted: 3, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + const result = await replayer('org_fresh'); + + // The replayer fed the loader the FULL union in source order — the crux of + // #3453. Pre-fix it would have replayed only app A's snapshot. + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].seeds.map((s: any) => s.object)).toEqual([ + 'crm_account', + 'crm_contact', + 'hot_lead', + ]); + // Tenant-scoped, upsert, multi-pass — as the org replay requires. + expect(loadCalls[0].config).toMatchObject({ + defaultMode: 'upsert', + multiPass: true, + organizationId: 'org_fresh', + }); + expect(result).toMatchObject({ inserted: 3 }); + }); + + it('a later marketplace merge is visible to the already-registered replayer (live read)', async () => { + const { ctx, services } = makeBootCtx(); + await new AppPlugin(appBundle('config-app-a', ds('crm_account', 'a1'))).start(ctx); + + const replayer = services.get('seed-replayer'); + expect(typeof replayer).toBe('function'); + + // Org #1 is created BEFORE the marketplace install: only app A's seed. + await replayer('org_one'); + expect(loadCalls.at(-1).seeds.map((s: any) => s.object)).toEqual(['crm_account']); + + // Marketplace installs AFTER the replayer closure was built… + mergeSeedDatasets(ctx, [ds('hot_lead', 'm1')]); + + // …and org #2 replays BOTH, because the closure re-reads the live list. + await replayer('org_two'); + expect(loadCalls.at(-1).seeds.map((s: any) => s.object)).toEqual(['crm_account', 'hot_lead']); + }); +}); diff --git a/packages/runtime/src/seed-datasets.ts b/packages/runtime/src/seed-datasets.ts new file mode 100644 index 000000000..e20a9ea15 --- /dev/null +++ b/packages/runtime/src/seed-datasets.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared `seed-datasets` registry — the multi-tenant seed-replay contract (#3453). + * + * In multi-tenant deployments (enterprise `@objectstack/organizations`) a brand-new + * org gets its own private copy of every artifact's demo data by REPLAYING the + * kernel's `seed-datasets` list on the `sys_organization` insert (Salesforce-sandbox + * style). That list must therefore hold the UNION of every seed source: every + * config-declared app AND every marketplace package installed at runtime. + * + * Two framework traps used to shrink that union down to just the first source — + * the same pair {@link recordSeedOutcome} was hardened against for seed-summary + * (#3444): + * + * ① The standard `PluginContext` (packages/core/src/kernel.ts) exposes + * `getService`/`registerService` but NO `.kernel` handle, so the old + * `(ctx as any).kernel?.getService('seed-datasets')` read was ALWAYS + * `undefined`. Each source then saw "nothing registered" and overwrote the + * list with only its own datasets instead of extending it. + * ② `registerService` THROWS on a duplicate name, so the second source's + * re-register was swallowed by the caller's try/catch — its datasets (and, + * in AppPlugin's case, its replayer) silently lost. + * + * The fix mirrors `recordSeedOutcome`: register ONE mutable array the first time, + * and have every later source read it back (through the context's OWN resolver + * first, a raw kernel handle second) and push in place. The per-org replayer reads + * {@link readSeedDatasets} at invoke time, so it always replays the full union — + * including datasets appended AFTER its closure was built (a second config app, a + * marketplace install). + */ + +/** The subset of a kernel / plugin context this module pokes at. */ +interface SeedDatasetHost { + kernel?: { getService?: (n: string) => unknown; registerService?: (n: string, v: unknown) => unknown }; + getService?: (n: string) => unknown; + registerService?: (n: string, v: unknown) => unknown; +} + +const SERVICE_NAME = 'seed-datasets'; +const REPLAYER_SERVICE_NAME = 'seed-replayer'; + +/** + * Read the live `seed-datasets` array. Preference order matters: the plugin + * context's own `getService` resolves against the running kernel and is the ONLY + * channel that works on a standard `PluginContext` (which has no `.kernel`); the + * raw-kernel fallback covers the rare case where a bare kernel is passed as `ctx`. + * Both throw when the service is unregistered — swallowed and reported as absent. + */ +export function readSeedDatasets(ctx: unknown): unknown[] | undefined { + const c = ctx as SeedDatasetHost | null | undefined; + if (typeof c?.getService === 'function') { + try { const v = c.getService(SERVICE_NAME); if (Array.isArray(v)) return v; } catch { /* unregistered */ } + } + if (typeof c?.kernel?.getService === 'function') { + try { const v = c.kernel.getService(SERVICE_NAME); if (Array.isArray(v)) return v; } catch { /* unregistered */ } + } + return undefined; +} + +/** + * Append `datasets` onto the shared `seed-datasets` service, register-once-then- + * mutate. Returns the live array so the caller can log the running total. + * + * `getService` hands back the SAME array reference, so an already-present list is + * mutated in place and only a brand-new list is registered — the ONE way to avoid + * the duplicate-register throw (defect ②) while still accumulating every source + * (defect ①). Best-effort: a failed register never propagates, so a + * banner/replay concern can't tear down boot; the caller still gets the (now + * unregistered) list back. + */ +export function mergeSeedDatasets(ctx: unknown, datasets: readonly unknown[]): unknown[] { + const c = ctx as SeedDatasetHost | null | undefined; + const current = readSeedDatasets(ctx); + const list: unknown[] = Array.isArray(current) ? current : []; + if (datasets.length > 0) list.push(...datasets); + + // Only the first source registers; every later one mutated the shared list + // above and must NOT re-register (that is the throw defect ② guards against). + if (!Array.isArray(current)) { + try { + if (typeof c?.kernel?.registerService === 'function') c.kernel.registerService(SERVICE_NAME, list); + else if (typeof c?.registerService === 'function') c.registerService(SERVICE_NAME, list); + } catch { /* never let seed wiring break boot */ } + } + return list; +} + +/** + * Register the per-org `seed-replayer` callable exactly once. Subsequent config + * apps reuse the first replayer — which reads the live (now-appended) dataset + * list on every call — instead of tripping the duplicate-register throw. + * + * Returns `true` when it newly registered, `false` when a replayer was already + * present (so the caller can log "reused" rather than "registered"). Best-effort: + * a lost check→register race is swallowed and reported as already-present. + */ +export function registerSeedReplayerOnce(ctx: unknown, replayer: unknown): boolean { + const c = ctx as SeedDatasetHost | null | undefined; + const alreadyRegistered = (): boolean => { + if (typeof c?.getService === 'function') { + try { return c.getService(REPLAYER_SERVICE_NAME) !== undefined; } catch { /* absent */ } + } + if (typeof c?.kernel?.getService === 'function') { + try { return c.kernel.getService(REPLAYER_SERVICE_NAME) !== undefined; } catch { /* absent */ } + } + return false; + }; + if (alreadyRegistered()) return false; + try { + if (typeof c?.kernel?.registerService === 'function') { c.kernel.registerService(REPLAYER_SERVICE_NAME, replayer); return true; } + if (typeof c?.registerService === 'function') { c.registerService(REPLAYER_SERVICE_NAME, replayer); return true; } + } catch { /* concurrent register — treat as already present */ } + return false; +}