Skip to content

Commit 810a3a2

Browse files
os-zhuangclaude
andauthored
fix(runtime,cloud-connection): multi-tenant seed replay covers every source, not just the first (#3453) (#3478)
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 — the same pair #3444 fixed for seed-summary — shrank it to just the first source: - the standard PluginContext has no `.kernel` handle, so `(ctx as any).kernel?.getService('seed-datasets')` always read undefined and each source clobbered the list with only its own datasets; and - `registerService` throws on a duplicate name, so the second source's re-register was swallowed and its datasets (and, for a config app, its replayer) were silently lost. `seed-datasets` is now a single shared array, registered once and mutated in place by every source via a new `mergeSeedDatasets` helper that reads through the context's own resolver first. AppPlugin's per-org replayer reads that live list at invoke time — not a captured snapshot — and is itself registered once and reused by later config apps, so a new org replays the full union. Seam-level unit tests only (accumulation across app + marketplace sources; the replayer reads the live union); true multi-tenant e2e needs the enterprise `@objectstack/organizations` plugin, which lives in the cloud repo. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7aea626 commit 810a3a2

6 files changed

Lines changed: 465 additions & 29 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/cloud-connection": patch
4+
---
5+
6+
fix(runtime,cloud-connection): multi-tenant seed replay covers every source, not just the first (#3453)
7+
8+
In multi-tenant deployments (enterprise `@objectstack/organizations`) a brand-new org
9+
gets its own private copy of demo data by replaying the kernel's `seed-datasets` list
10+
on the `sys_organization` insert. That list is meant to hold the union of every seed
11+
source — every config-declared app AND every marketplace package — but two framework
12+
traps (the same pair #3444 fixed for seed-summary) shrank it to just the first source:
13+
14+
- The standard `PluginContext` exposes `getService`/`registerService` but has NO
15+
`.kernel` handle, so `(ctx as any).kernel?.getService('seed-datasets')` always read
16+
`undefined`. Each source then saw "nothing registered" and overwrote the list with
17+
only its own datasets instead of extending it.
18+
- `registerService` throws on a duplicate name, so the second source's re-register was
19+
swallowed by the surrounding try/catch — its datasets (and, for a config app, its
20+
replayer) silently lost.
21+
22+
Net effect: with two config apps, or a config app plus marketplace packages, a new org
23+
replayed only the first app's seeds.
24+
25+
The fix mirrors #3444's seed-summary hardening: `seed-datasets` is now a single shared
26+
array, registered once and mutated in place by every source through a new
27+
`mergeSeedDatasets` helper that reads via the context's own resolver first. AppPlugin's
28+
per-org replayer reads that live list at invoke time instead of a captured snapshot, so
29+
it replays the full union — including datasets merged after its closure was built — and
30+
the replayer itself is registered once and reused by later config apps.
31+
32+
Covered by seam-level unit tests (accumulation across app + marketplace sources; the
33+
replayer reads the live union). True multi-tenant end-to-end coverage requires the
34+
enterprise `@objectstack/organizations` plugin, which lives in the cloud repo.

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,57 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
320320
} catch { /* banner summary is best-effort — never break the heal */ }
321321
};
322322

323+
/**
324+
* Merge `datasets` onto the SHARED `seed-datasets` service via the runtime's
325+
* register-once-then-mutate helper (#3453), so THIS package's seeds accumulate
326+
* alongside every config app's and every other package's rather than clobbering
327+
* them — the per-org replayer (AppPlugin) replays the whole union on the next
328+
* `sys_organization` insert. Resolved lazily through `@objectstack/runtime` and
329+
* guarded exactly like {@link recordSeedSummary}: a runtime that predates the
330+
* helper — or a test that mocks the module without it — falls back to an
331+
* equivalent inline merge. Returns the post-merge total for the log line.
332+
*/
333+
private mergeSeedDatasetsIntoKernel = async (ctx: PluginContext, datasets: any[]): Promise<number> => {
334+
try {
335+
const mod: any = await import('@objectstack/runtime');
336+
if (typeof mod?.mergeSeedDatasets === 'function') {
337+
const list = mod.mergeSeedDatasets(ctx, datasets);
338+
return Array.isArray(list) ? list.length : datasets.length;
339+
}
340+
} catch { /* fall through to the inline merge below */ }
341+
return this.mergeSeedDatasetsInline(ctx, datasets);
342+
};
343+
344+
/**
345+
* Fallback for {@link mergeSeedDatasetsIntoKernel} when the runtime helper is
346+
* unavailable (older build / mocked module). Same register-once-then-mutate:
347+
* read the live list through the context's OWN resolver first — a standard
348+
* PluginContext has no `.kernel`, which is precisely why the old
349+
* `(ctx as any).kernel` read clobbered instead of accumulating — push in place,
350+
* and register only when the service does not yet exist so a second source
351+
* cannot trip the duplicate-register throw. Returns the post-merge total.
352+
*/
353+
private mergeSeedDatasetsInline = (ctx: PluginContext, datasets: any[]): number => {
354+
const c = ctx as any;
355+
const read = (): any[] | undefined => {
356+
if (typeof c?.getService === 'function') {
357+
try { const v = c.getService('seed-datasets'); if (Array.isArray(v)) return v; } catch { /* absent */ }
358+
}
359+
if (typeof c?.kernel?.getService === 'function') {
360+
try { const v = c.kernel.getService('seed-datasets'); if (Array.isArray(v)) return v; } catch { /* absent */ }
361+
}
362+
return undefined;
363+
};
364+
const current = read();
365+
const list: any[] = Array.isArray(current) ? current : [];
366+
list.push(...datasets);
367+
if (!Array.isArray(current)) {
368+
if (typeof c?.kernel?.registerService === 'function') c.kernel.registerService('seed-datasets', list);
369+
else if (typeof c?.registerService === 'function') c.registerService('seed-datasets', list);
370+
}
371+
return list.length;
372+
};
373+
323374
private handleInstall = async (c: any, ctx: PluginContext): Promise<Response> => {
324375
const userId = await this.requireAuthenticatedUser(c, ctx);
325376
if (!userId) {
@@ -905,16 +956,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
905956

906957
if (datasets.length > 0) {
907958
try {
908-
const kernel: any = (ctx as any).kernel;
909-
let existing: any[] = [];
910-
try {
911-
const v = kernel?.getService?.('seed-datasets');
912-
if (Array.isArray(v)) existing = v;
913-
} catch { /* unset */ }
914-
const merged = [...existing, ...datasets];
915-
if (kernel?.registerService) kernel.registerService('seed-datasets', merged);
916-
else (ctx as any).registerService?.('seed-datasets', merged);
917-
ctx.logger?.info?.(`[MarketplaceInstallLocal] merged ${datasets.length} seed dataset(s) into kernel (total: ${merged.length})`);
959+
const total = await this.mergeSeedDatasetsIntoKernel(ctx, datasets);
960+
ctx.logger?.info?.(`[MarketplaceInstallLocal] merged ${datasets.length} seed dataset(s) into kernel (total: ${total})`);
918961
} catch (err: any) {
919962
ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to merge seed-datasets: ${err?.message ?? err}`);
920963
}

packages/runtime/src/app-plugin.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { assertProtocolCompat } from '@objectstack/metadata-core';
55
import { resolveMultiOrgEnabled } from '@objectstack/types';
66
import { SeedLoaderService } from './seed-loader.js';
77
import { recordSeedOutcome } from './seed-summary.js';
8+
import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js';
89
import { loadDisabledPackageIds } from './package-state-store.js';
910
import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
1011
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
@@ -750,31 +751,29 @@ export class AppPlugin implements Plugin {
750751
// captures the SeedLoaderService closure and exposes a
751752
// narrow `(orgId) => Promise<summary>` surface.
752753
try {
753-
const kernel: any = (ctx as any).kernel;
754-
const existing = (() => {
755-
try { return kernel?.getService?.('seed-datasets'); } catch { return undefined; }
756-
})();
757-
const merged = Array.isArray(existing)
758-
? [...existing, ...normalizedDatasets]
759-
: normalizedDatasets;
760-
const registerSvc = (name: string, value: any) => {
761-
if (kernel?.registerService) kernel.registerService(name, value);
762-
else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService(name, value);
763-
};
764-
registerSvc('seed-datasets', merged);
754+
// #3453: append this app's datasets onto the SHARED `seed-datasets`
755+
// array (register-once-then-mutate). Reading through the context's
756+
// own resolver — NOT the non-existent `(ctx as any).kernel`, which was
757+
// always undefined — means a SECOND config app (or a marketplace
758+
// install) extends the list instead of clobbering it or tripping the
759+
// duplicate-register throw. The per-org replayer below re-reads this
760+
// live list on every call, so a new org replays the full union.
761+
const sharedDatasets = mergeSeedDatasets(ctx, normalizedDatasets);
765762

766-
const metadataNow = ctx.getService('metadata') as IMetadataService | undefined;
767763
const loggerRef = ctx.logger;
768764
const replayer = async (organizationId: string) => {
769765
if (!organizationId) return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] };
770-
const md = metadataNow ?? (ctx.getService('metadata') as IMetadataService | undefined);
766+
const md = ctx.getService('metadata') as IMetadataService | undefined;
771767
if (!md) {
772768
loggerRef.warn('[seed-replayer] metadata service unavailable');
773769
return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] };
774770
}
775-
const datasetsNow = (() => {
776-
try { return kernel?.getService?.('seed-datasets'); } catch { return merged; }
777-
})() ?? merged;
771+
// Read the LIVE shared list on every replay — NOT the
772+
// `sharedDatasets` snapshot captured when this closure was built.
773+
// An org created after a later app/marketplace install must still
774+
// replay their seeds (the #3453 fix). Fall back to the snapshot
775+
// only if the service somehow vanished.
776+
const datasetsNow = readSeedDatasets(ctx) ?? sharedDatasets;
778777
if (!Array.isArray(datasetsNow) || datasetsNow.length === 0) {
779778
return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] };
780779
}
@@ -806,8 +805,15 @@ export class AppPlugin implements Plugin {
806805
errors: result.errors,
807806
};
808807
};
809-
registerSvc('seed-replayer', replayer);
810-
ctx.logger.info(`[Seeder] Registered ${normalizedDatasets.length} datasets + replayer on kernel (total seeds: ${merged.length})`);
808+
// Register the replayer once; a later config app reuses the first
809+
// one, which reads the now-extended shared list on every call — so
810+
// there is no duplicate-register throw and no lost datasets (#3453).
811+
const replayerRegistered = registerSeedReplayerOnce(ctx, replayer);
812+
ctx.logger.info(
813+
replayerRegistered
814+
? `[Seeder] Registered ${normalizedDatasets.length} datasets + replayer (total seeds: ${sharedDatasets.length})`
815+
: `[Seeder] Appended ${normalizedDatasets.length} datasets to shared registry; reused existing replayer (total seeds: ${sharedDatasets.length})`,
816+
);
811817
} catch (e: any) {
812818
ctx.logger.warn('[Seeder] Failed to register seed-datasets/seed-replayer service', { error: e?.message });
813819
}

packages/runtime/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export { SeedLoaderService } from './seed-loader.js';
2323
// contract shared by AppPlugin and the marketplace rehydrate/heal path.
2424
export { recordSeedOutcome } from './seed-summary.js';
2525
export type { SeedSourceOutcome } from './seed-summary.js';
26+
// Multi-tenant seed-replay registry (#3453) — the register-once-then-mutate
27+
// contract shared by AppPlugin and the marketplace install path so a new org
28+
// replays the UNION of every seed source, not just the first one.
29+
export { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js';
2630
// External Datasource Federation — boot-validation gate (ADR-0015, Gate 2)
2731
export { ExternalValidationPlugin, createExternalValidationPlugin } from './external-validation-plugin.js';
2832
export type { ExternalSchemaDriftEvent } from './external-validation-plugin.js';

0 commit comments

Comments
 (0)