diff --git a/.changeset/install-local-seed-replayer-registration.md b/.changeset/install-local-seed-replayer-registration.md new file mode 100644 index 0000000000..21cd9f82f2 --- /dev/null +++ b/.changeset/install-local-seed-replayer-registration.md @@ -0,0 +1,9 @@ +--- +'@objectstack/cloud-connection': patch +--- + +Marketplace install-local now registers the per-organization seed replayer alongside its dataset merge, so organizations founded after an install are no longer empty. + +Installing a package merged its `data` blocks onto the kernel's shared `seed-datasets` service but never registered the `seed-replayer` service that consumes them. That replayer is registered in `AppPlugin`'s seeder path, so a host runtime declaring no seed data of its own — `objects: []`, no `data`, which is exactly the shape a marketplace install targets — ended up with datasets present and no replayer. On a walled (`isolated` / `group`) deployment the org-scoping middleware then found the datasets, found no replayer, and did nothing: every organization founded after the install received zero rows of the installed app, while the installer's own organization looked correct because it had been seeded inline at install time. + +`applySideEffects` now calls the runtime's `registerSeedReplayerOnce` next to the merge, on both the install and the rehydrate path. Registration is register-once by construction, so a host that already has a replayer keeps it and is unaffected; the incumbent re-reads the same shared list and replays the newly installed datasets too. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 454b08cde4..78a8d4be33 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -71,7 +71,7 @@ import { } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; -import type { IHttpServer, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -437,11 +437,23 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * 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 + * them — a per-org replayer 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. + * + * ⚠️ [#9070] This merge is only half of the contract, and the missing half used + * to be assumed rather than provided: the docblock said "the per-org replayer + * (AppPlugin)" as if one always existed. It does not. `registerSeedReplayerOnce` + * lives in AppPlugin's seeder path, so on a host runtime that declares no seed + * data of its own — `objects: []`, no `data`, which is precisely the shape a + * marketplace install targets — `seed-datasets` ends up populated and + * `seed-replayer` absent. The middleware then finds datasets, finds no replayer, + * logs `datasets present but no replayer registered`, and every organization + * founded after the install boots EMPTY. The merge landed and the consumer never + * did. {@link registerSeedReplayerIntoKernel} is that consumer; the two are + * called together so the split cannot recur. */ private mergeSeedDatasetsIntoKernel = async (ctx: PluginContext, datasets: any[]): Promise => { try { @@ -465,16 +477,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { */ 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 current = this.readSharedSeedDatasets(ctx); const list: any[] = Array.isArray(current) ? current : []; list.push(...datasets); if (!Array.isArray(current)) { @@ -484,6 +487,138 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return list.length; }; + /** + * Read the LIVE shared `seed-datasets` array, or `undefined` when nothing has + * registered one yet. Resolution order is load-bearing and mirrors the runtime + * helper: the context's OWN resolver first — a standard `PluginContext` has no + * `.kernel` handle at all, which is exactly why the old `(ctx as any).kernel` + * read was always `undefined` (#3453) — with the raw-kernel handle second for + * the rare embedding that passes a bare kernel as `ctx`. Both throw when the + * service is unregistered; swallowed and reported as absent. + */ + private readSharedSeedDatasets = (ctx: PluginContext): any[] | undefined => { + const c = ctx as any; + 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; + }; + + /** + * [#9070] Register the per-organization `seed-replayer` callable alongside the + * dataset merge, through the runtime's `registerSeedReplayerOnce` helper. + * + * This is the consumer half of {@link mergeSeedDatasetsIntoKernel}. Without it a + * walled deployment installs an app and then hands every organization founded + * afterwards an EMPTY copy of it: the org-scoping middleware reads + * `seed-datasets`, finds the union it needs, finds no `seed-replayer`, and does + * nothing. The installer's own organization is the only one that ever gets rows, + * and it gets them from the install-time inline seed rather than from the replay. + * + * Register-ONCE is the whole safety argument, and it belongs to the helper rather + * than to a caller-side "is there one already?" test: a host that already has a + * replayer (any `AppPlugin` app with seed data) keeps it, and the incumbent reads + * the same live shared list, so it replays this package's datasets too. Nothing + * is displaced and nothing is duplicated — which is why this can be called + * unconditionally on both the install and the rehydrate path. + * + * Guarded exactly like {@link mergeSeedDatasetsIntoKernel}: an older runtime + * build — or a test that mocks `@objectstack/runtime` without the helper, which + * five sibling suites in this package do — falls back to the inline equivalent. + * The fallback is not decoration: a silent no-op there would restore the very + * defect this method exists to close, and it would be invisible. + * + * Returns `true` when it newly registered, `false` when a replayer was already + * present, so the caller can log "reused" rather than "registered". + */ + private registerSeedReplayerIntoKernel = async (ctx: PluginContext): Promise => { + const replayer = this.buildSeedReplayer(ctx); + try { + const mod: any = await import('@objectstack/runtime'); + if (typeof mod?.registerSeedReplayerOnce === 'function') { + return mod.registerSeedReplayerOnce(ctx, replayer) === true; + } + } catch { /* fall through to the inline registration below */ } + return this.registerSeedReplayerInline(ctx, replayer); + }; + + /** + * Fallback for {@link registerSeedReplayerIntoKernel}, mirroring the runtime's + * `registerSeedReplayerOnce` byte for byte in behaviour: probe first, register + * only on absence, and treat a lost check→register race (the duplicate-register + * throw) as "already present". Best-effort throughout — a replay concern must + * never tear down an install. + */ + private registerSeedReplayerInline = (ctx: PluginContext, replayer: unknown): boolean => { + const c = ctx as any; + const alreadyRegistered = (): boolean => { + if (typeof c?.getService === 'function') { + try { if (c.getService('seed-replayer') !== undefined) return true; } catch { /* absent */ } + } + if (typeof c?.kernel?.getService === 'function') { + try { if (c.kernel.getService('seed-replayer') !== undefined) return true; } catch { /* absent */ } + } + return false; + }; + if (alreadyRegistered()) return false; + try { + if (typeof c?.kernel?.registerService === 'function') { c.kernel.registerService('seed-replayer', replayer); return true; } + if (typeof c?.registerService === 'function') { c.registerService('seed-replayer', replayer); return true; } + } catch { /* concurrent register — treat as already present */ } + return false; + }; + + /** + * The `(organizationId) => Promise` callable registered above — the + * narrow surface the org-scoping middleware invokes on a `sys_organization` + * insert, so that package never has to import `@objectstack/runtime`. + * + * Two properties it must have, both learned from the AppPlugin replayer it + * mirrors (`packages/runtime/src/app-plugin.ts`): + * + * • It reads the LIVE shared list on every call, never a snapshot captured + * when the closure was built. An organization founded after a LATER + * marketplace install must still replay that install's seeds — the whole + * point of accumulating one union (#3453). + * • `errors` is the loader's ERROR ARRAY, not a count. The consumer reports + * `summary?.errors?.length` and samples `summary?.errors?.slice(0, 5)`; + * handing it a number makes both read as "0 error(s)" through optional + * chaining — a machine-readable summary that lies about a failed replay + * rather than one that reports it. + * + * Never throws: a composition gap (no objectql/metadata) answers the zero + * summary and says so once, exactly like the AppPlugin replayer, leaving the + * middleware's own fallbacks to decide what to do about it. + */ + private buildSeedReplayer = (ctx: PluginContext) => { + const empty = () => ({ inserted: 0, updated: 0, skipped: 0, errors: [] as any[] }); + return async (organizationId: string) => { + if (!organizationId) return empty(); + const datasetsNow = this.readSharedSeedDatasets(ctx); + if (!Array.isArray(datasetsNow) || datasetsNow.length === 0) return empty(); + // Both slots' declared contracts, not `any` (#4127/#4251): these + // lookups are NEW code, so they carry the contract rather than + // riding this file's grandfathered entry in + // `scripts/slot-lookup-baseline.json` — the same spelling the + // session-resolver below uses for `objectql`. Split declaration and + // lookup is the FOURTH erasure shape, and an untyped `let` erases + // the slot just as `const ql: any = …` does. + let ql: IObjectQLEngine | undefined; + let metadata: IMetadataService | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + try { metadata = ctx.getService('metadata'); } catch { /* no metadata service */ } + if (!ql || !metadata) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] seed-replayer: objectql/metadata unavailable — org ${organizationId} not seeded`); + return empty(); + } + const s = await this.runInlineSeed(ctx, datasetsNow, organizationId); + return { inserted: s.inserted, updated: s.updated, skipped: s.skipped, errors: s.errorList }; + }; + }; + private handleInstall = async (c: any, ctx: PluginContext): Promise => { // [#8976] The sharpest of the four: this door accepts an INLINE manifest // and turns it into `syncSchemas()` against the shared database. @@ -1270,13 +1405,36 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to merge seed-datasets: ${err?.message ?? err}`); } + // [#9070] …and register the CONSUMER of that merge, right here, so the + // two cannot land apart again. Merging seeds onto a kernel with no + // `seed-replayer` is a no-op with a success log: every organization + // founded after this install boots empty. Register-once means a host + // that already has a replayer is untouched (that is what the helper is + // for), and the incumbent replays this package's datasets anyway + // because it re-reads the same shared list. Runs on the rehydrate path + // too (`seedNow: false`), which is where a restarted runtime gets its + // replayer back. + try { + const registered = await this.registerSeedReplayerIntoKernel(ctx); + ctx.logger?.info?.( + registered + ? `[MarketplaceInstallLocal] registered per-org seed-replayer for ${appId}` + : `[MarketplaceInstallLocal] per-org seed-replayer already registered — reused for ${appId}`, + ); + } catch (err: any) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to register seed-replayer: ${err?.message ?? err}`); + } } // ── 3. Optional immediate seed ─────────────────────────────────── - // Always seed inline via SeedLoaderService — don't rely on the - // `seed-replayer` registered by AppPlugin since (a) it isn't - // registered when the host runtime has no AppPlugin app with - // seed data, and (b) its closure may use stale datasets. In + // Always seed inline via SeedLoaderService — don't route the + // install's OWN seed through the `seed-replayer` service, since + // (a) whoever registered it may be a foreign source (AppPlugin, + // or step 2's registration on an earlier install) and (b) its + // closure may use stale datasets. Note the replayer is now + // guaranteed to EXIST by step 2 (#9070) — that guarantee is + // about future organizations, and does not make it the right + // instrument for seeding the caller's own org right now. In // multi-tenant mode we pass `organizationId` so the loader // writes tenant-scoped rows the same way AppPlugin's // single-tenant branch + SecurityPlugin's per-org replay do. @@ -1328,7 +1486,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, datasets: any[], organizationId?: string, - ): Promise<{ inserted: number; updated: number; skipped: number; errors: number; droppedRefs: number; errorSample?: string }> => { + ): Promise<{ inserted: number; updated: number; skipped: number; errors: number; errorList: any[]; droppedRefs: number; errorSample?: string }> => { const ql: any = ctx.getService('objectql'); let metadata: any; try { metadata = ctx.getService('metadata'); } catch { /* none */ } @@ -1354,6 +1512,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { updated: result.summary.totalUpdated, skipped: result.summary.totalSkipped ?? 0, errors: result.errors.length, + // [#9070] The raw array beside the count. The per-org replayer's + // consumer reports `errors.length` and samples `errors.slice(0, 5)`; + // a count handed to it reads as "0 error(s)" through its optional + // chaining. Every other caller here wants the count, so both ship. + errorList: Array.isArray(result.errors) ? result.errors : [], // Reference fields dropped from rows that WERE written (#3932) — // invisible in every row count, so carried explicitly. droppedRefs: result.summary.totalReferencesDropped ?? 0, diff --git a/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts new file mode 100644 index 0000000000..4acb05bba6 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#9070] The marketplace install merges seed datasets but never registered the +// thing that CONSUMES them. +// +// ## The shape this file pins, and why nothing else caught it +// +// `applySideEffects` step 2 appends the installed package's `data` blocks onto +// the shared `seed-datasets` service (#3453 fixed that half). The per-organization +// replay that turns those datasets into a new org's private copy is a SEPARATE +// service, `seed-replayer`, and it is registered in `AppPlugin`'s seeder path — +// i.e. only when the HOST runtime declares seed data of its own. +// +// A runtime that declares no data of its own — `objects: []`, no `data`, which is +// exactly what a marketplace-install target looks like and exactly what +// `apps/objectos-ee` is — therefore ended up with `seed-datasets` POPULATED and +// `seed-replayer` ABSENT. The org-scoping middleware reads both, finds the +// datasets, finds no replayer, logs `datasets present but no replayer registered` +// and does nothing. Every organization founded after the install boots EMPTY, +// while the installer's own organization looks fine — it got its rows from the +// install-time inline seed, not from the replay. That asymmetry is why the defect +// survived: the probe everyone reaches for reports success. +// +// ⚠️ So a single-environment boot proves NOTHING here, and neither does asserting +// that the merge happened. Every test below is the walled / no-host-data +// direction, and the load-bearing assertion is always about the REPLAYER — +// registered, and actually replaying the union into a named organization. +// +// ## Shape of the harness +// +// Real plugin, real routes, real ledger on a real temp dir. The context is a +// standard `PluginContext` with NO `.kernel` handle and a registry that behaves +// like the kernel's: `getService` THROWS on a miss, `registerService` THROWS on a +// duplicate (`packages/core/src/plugin-loader.ts`). Both throws are load-bearing — +// they are the two framework traps #3453 was about, and a permissive fake would +// let a broken registration pass. +// +// Nothing in this file mounts an AppPlugin, on purpose: the absence of a host +// replayer IS the scenario. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let seedResult: any = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; +let loadCalls: any[] = []; + +/** + * Whether the mocked `@objectstack/runtime` exposes the seed helpers at all. + * + * Both states are real. In production the helpers exist and the plugin must use + * them (register-once lives there, not in a caller-side probe). Under an older + * runtime build — or in the five sibling suites in this package that mock this + * module with only `SeedLoaderService` + `recordSeedOutcome` — they do not, and + * the plugin must fall back to the inline equivalent. A silent no-op in the + * fallback would restore this exact defect, invisibly, so both paths are pinned. + * + * Read through getters below so the toggle applies per call (the plugin resolves + * the module lazily, at call time). + */ +let helpersPresent = true; + +/** + * Faithful-enough stand-ins. The REAL register-once/merge semantics are pinned + * where they live — `packages/runtime/src/seed-datasets.test.ts` — so these exist + * only to let this file observe that the plugin routes through them and honours + * what they answer. + */ +const helperRegisterOnce = vi.fn((ctx: any, replayer: unknown): boolean => { + try { if (ctx.getService('seed-replayer') !== undefined) return false; } catch { /* absent */ } + try { ctx.registerService('seed-replayer', replayer); return true; } catch { return false; } +}); +const helperMerge = vi.fn((ctx: any, datasets: readonly unknown[]): unknown[] => { + let current: any[] | undefined; + try { const v = ctx.getService('seed-datasets'); if (Array.isArray(v)) current = v; } catch { /* absent */ } + const list: any[] = current ?? []; + list.push(...datasets); + if (!current) { try { ctx.registerService('seed-datasets', list); } catch { /* best effort */ } } + return list; +}); + +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: any) { loadCalls.push(request); return seedResult; } + }, + recordSeedOutcome: vi.fn(), + get mergeSeedDatasets() { return helpersPresent ? helperMerge : undefined; }, + get registerSeedReplayerOnce() { return helpersPresent ? helperRegisterOnce : undefined; }, +})); +vi.mock('@objectstack/spec/data', () => ({ + SeedLoaderRequestSchema: { parse: (x: any) => x }, +})); + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** + * A standard `PluginContext` over a kernel-faithful service registry. + * `preRegistered` seeds services that already exist at install time — used to + * stand up the "this host DOES have a replayer" control. + */ +function makeCtx( + rawApp: any, + services: Record, + effectivePosture: string, + preRegistered: Record = {}, +) { + const hooks = new Map(); + const registry = new Map(Object.entries(preRegistered)); + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name === 'tenancy') return { posture: effectivePosture }; + if (registry.has(name)) return registry.get(name); + const svc = services[name]; + // Kernel semantics: a miss THROWS, it does not answer undefined. + if (svc === undefined) throw new Error(`[Kernel] Service '${name}' not found.`); + return svc; + }, + registerService: (name: string, svc: unknown) => { + // Kernel semantics: a duplicate THROWS. This is framework trap ② of + // #3453 — the one that used to swallow a second source's replayer. + if (registry.has(name) || services[name] !== undefined) { + throw new Error(`Service '${name}' already registered`); + } + registry.set(name, svc); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + return { + ctx, + registry, + /** Resolve without throwing, so an absent service is an ASSERTION failure. */ + peek: (name: string) => registry.get(name), + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + header: () => undefined, + }, + json, + }; +} + +/** The installed package: it carries the data. */ +const MANIFEST = { + id: 'app.test.crm', + version: '1.0.0', + objects: [{ name: 'crm_x', fields: { name: { type: 'text' } } }], + data: [{ object: 'crm_x', records: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }] }], +}; + +/** A second package, for the union assertion. */ +const MANIFEST_2 = { + id: 'app.test.helpdesk', + version: '1.0.0', + objects: [{ name: 'hd_ticket', fields: { subject: { type: 'text' } } }], + data: [{ object: 'hd_ticket', records: [{ id: 't1', subject: 't1' }] }], +}; + +const SERVICES = () => ({ + manifest: { register: vi.fn() }, + auth: installerAuthService(), + objectql: withInstallerGrants({ + syncSchemas: async () => undefined, + find: vi.fn(async () => []), + }), + metadata: {}, + driver: { delete: vi.fn(async () => true) }, +}); + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-replayer-')); + seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + loadCalls = []; + helpersPresent = true; + helperRegisterOnce.mockClear(); + helperMerge.mockClear(); + // The walled deployment the defect was measured on. + process.env.OS_TENANCY_POSTURE = 'isolated'; + delete process.env.OS_MULTI_ORG_ENABLED; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; +}); + +/** + * Boot the plugin on a host with no seed data of its own and install `manifest` + * through the real route. Walled posture with no active organization on the + * request, which is the measured shape AND a useful property for these + * assertions: the install-time inline seed is skipped, so the seed loader is + * untouched until something invokes the REPLAYER. + */ +async function installOn( + harness: ReturnType, + rawApp: any, + manifest: any = MANIFEST, + plugin?: MarketplaceInstallLocalPlugin, +) { + const p = plugin ?? new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + if (!plugin) { + await p.start(harness.ctx as any); + await harness.fire(); + } + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest })); + return { res, plugin: p }; +} + +function freshHarness(preRegistered: Record = {}) { + const rawApp = makeRawApp(); + const harness = makeCtx(rawApp, SERVICES(), 'isolated', preRegistered); + return { rawApp, harness }; +} + +describe('#9070 — install-local registers the per-org seed replayer, not just the datasets', () => { + it('registers `seed-replayer` on a host runtime that declares no data of its own', async () => { + // THE regression. Before the fix `seed-datasets` was populated and + // `seed-replayer` was absent, which is precisely the state that makes the + // org-scoping middleware log "datasets present but no replayer + // registered" and leave every future organization empty. + const { rawApp, harness } = freshHarness(); + const { res } = await installOn(harness, rawApp); + + expect(res.payload?.success).toBe(true); + expect(harness.peek('seed-datasets')).toHaveLength(1); + expect(typeof harness.peek('seed-replayer')).toBe('function'); + }); + + it('the registered replayer seeds a NAMED organization from the merged datasets', async () => { + // Registration alone is not the contract — being invocable the way the + // middleware invokes it is. The install itself never ran the loader + // (walled, no active org), so every call recorded here belongs to the + // replay. + const { rawApp, harness } = freshHarness(); + await installOn(harness, rawApp); + expect(loadCalls).toHaveLength(0); + + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + const summary = await replayer('org_new'); + + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].config.organizationId).toBe('org_new'); + expect(loadCalls[0].seeds).toEqual(MANIFEST.data); + expect(summary.inserted).toBe(2); + }); + + it('replays the UNION — an org founded after a LATER install gets that install too', async () => { + // The reason the shared list exists at all (#3453). The replayer must read + // the live list at invoke time; a snapshot captured when the closure was + // built would hand the second package's customers an empty app. + const { rawApp, harness } = freshHarness(); + const { plugin } = await installOn(harness, rawApp); + await installOn(harness, rawApp, MANIFEST_2, plugin); + + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + await replayer('org_new'); + + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].seeds).toEqual([...MANIFEST.data, ...MANIFEST_2.data]); + }); + + it('reports `errors` as the loader ARRAY, the shape the consumer reads', async () => { + // The middleware logs `summary?.errors?.length` and samples + // `summary?.errors?.slice(0, 5)`. Hand it a count and both read through + // optional chaining as "0 error(s)" — a failed replay that reports clean. + seedResult = { + summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, + errors: [{ message: 'boom one' }, { message: 'boom two' }], + }; + const { rawApp, harness } = freshHarness(); + await installOn(harness, rawApp); + + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + const summary = await replayer('org_new'); + + expect(Array.isArray(summary.errors)).toBe(true); + expect(summary.errors).toHaveLength(2); + expect(summary.errors.slice(0, 5)[0].message).toBe('boom one'); + }); + + it('goes through the runtime helper when it is available', async () => { + // Register-once is the helper's guarantee, not a caller-side probe. The + // real semantics are pinned in packages/runtime/src/seed-datasets.test.ts. + const { rawApp, harness } = freshHarness(); + await installOn(harness, rawApp); + + expect(helperRegisterOnce).toHaveBeenCalledTimes(1); + expect(helperRegisterOnce.mock.calls[0][0]).toBe(harness.ctx); + expect(typeof helperRegisterOnce.mock.calls[0][1]).toBe('function'); + }); + + it('still registers when the runtime exposes no helper — the fallback is not decoration', async () => { + // An older runtime build, or any of the sibling suites that mock this + // module without the helpers. A no-op here would restore the defect and + // nothing would say so. + helpersPresent = false; + const { rawApp, harness } = freshHarness(); + await installOn(harness, rawApp); + + expect(helperRegisterOnce).not.toHaveBeenCalled(); + expect(harness.peek('seed-datasets')).toHaveLength(1); + expect(typeof harness.peek('seed-replayer')).toBe('function'); + + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + await replayer('org_new'); + expect(loadCalls[0].config.organizationId).toBe('org_new'); + }); + + it('never displaces a replayer the host already registered, and never throws', async () => { + // Register-once by construction: a host WITH an AppPlugin app that has + // seed data is unaffected. The incumbent reads the same shared list, so + // it replays this package's datasets anyway. A caller that instead + // re-registered would hit the duplicate-register throw (trap ② of #3453). + const incumbent = vi.fn(async () => ({ inserted: 0, updated: 0, skipped: 0, errors: [] })); + const { rawApp, harness } = freshHarness({ 'seed-replayer': incumbent }); + const { res } = await installOn(harness, rawApp); + + expect(res.payload?.success).toBe(true); + expect(harness.peek('seed-replayer')).toBe(incumbent); + expect(harness.peek('seed-datasets')).toHaveLength(1); + expect( + (harness.ctx.logger.warn as any).mock.calls.some((c: any[]) => + String(c[0]).includes('failed to register seed-replayer'), + ), + ).toBe(false); + }); + + it('registers on the REHYDRATE path too — a restarted runtime gets its replayer back', async () => { + // The kernel is new on every boot, so the registration has to happen again + // even though no install request arrives. Rehydrate calls applySideEffects + // with `seedNow: false`, which is why the registration lives beside the + // merge rather than inside the immediate-seed branch. + new LocalManifestSource(dir).write({ + packageId: 'pkg_1', + versionId: 'pkgv_1', + manifestId: MANIFEST.id, + version: MANIFEST.version, + manifest: MANIFEST, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + } as any); + + // No route call on this path — rehydrate runs off `kernel:ready` alone. + const { harness } = freshHarness(); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(harness.ctx as any); + await harness.fire(); + + expect(harness.peek('seed-datasets')).toHaveLength(1); + expect(typeof harness.peek('seed-replayer')).toBe('function'); + + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + await replayer('org_new'); + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].config.organizationId).toBe('org_new'); + }); + + it('is a TOTAL function — a composition gap answers the zero summary, never a throw', async () => { + // The middleware calls this inside a try/catch and falls back on failure, + // so a throw is survivable — but it is reported as "replay failed" and + // costs the operator a diagnosis. Mirror the AppPlugin replayer: say it + // once, answer zeros, leave the fallbacks free to run. + const rawApp = makeRawApp(); + const services: Record = SERVICES(); + const harness = makeCtx(rawApp, services, 'isolated'); + await installOn(harness, rawApp); + const replayer = harness.peek('seed-replayer') as (id: string) => Promise; + + // (a) no organization id — nothing to scope a replay to. + await expect(replayer('')).resolves.toEqual({ inserted: 0, updated: 0, skipped: 0, errors: [] }); + + // (b) the services the loader needs went away under it. + delete services.objectql; + delete services.metadata; + await expect(replayer('org_new')).resolves.toEqual({ inserted: 0, updated: 0, skipped: 0, errors: [] }); + + expect(loadCalls).toHaveLength(0); + expect( + (harness.ctx.logger.warn as any).mock.calls.some((c: any[]) => + String(c[0]).includes('seed-replayer: objectql/metadata unavailable'), + ), + ).toBe(true); + }); +});