diff --git a/.changeset/21665-seed-replay-per-organization-ids.md b/.changeset/21665-seed-replay-per-organization-ids.md new file mode 100644 index 00000000000..1b766a98950 --- /dev/null +++ b/.changeset/21665-seed-replay-per-organization-ids.md @@ -0,0 +1,15 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +A per-organization seed replay now gives each organization its own row identity. On a walled deployment, every organization created after the first used to start without the app's fixed-id seed rows. The showcase's `sys_business_unit` tree is one example. The replay inserted each authored `id` again, every insert was refused as a duplicate on the global primary key, and the parent references into those rows stayed unresolved. + +Clause-②: no + +- A row authored with an `id` keeps that id while no row holds it. The first organization a seed is replayed into is unchanged: it gets exactly the ids the seed authors. +- When another organization (or an organization-less row) already holds the authored id, the row gets an id derived from the authored id and the organization. A second replay into the same organization finds that row again, so it is not inserted twice. +- References in the same replay that name the authored id follow the row to its new id, in pass 1 and in pass 2. This includes a UUID-shaped authored id, which used to be kept verbatim and would have linked to another organization's row. +- The replay logs one `info` line per dataset that it re-identified. +- The rule lives in `SeedLoaderService`, so every load that names an organization follows it: the per-organization replayer, and package apply, draft publish and marketplace install into an organization. +- Boot seeding without an organization, dry runs and rows without an authored `id` are unchanged. +- ⛔ No schema, export, accepted input or error code changes. diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 1af7535c841..df690287d42 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -12,6 +12,7 @@ import type { ReferenceResolutionError, SeedLoadResultParsed, Seed, + EngineQueryOptions, } from '@objectstack/spec/data'; import { SeedLoaderConfigSchema, isMultiValueField, referenceTargetOf } from '@objectstack/spec/data'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; @@ -347,6 +348,28 @@ function localeScopeLabel(dataset: Seed): string { return `${dataset.object} (locale: ${(dataset.locale as string[]).join(', ')})`; } +/** + * [#21665] The id a seed row authored as `authoredId` holds in `organizationId` + * when a per-organization replay cannot give it the authored id itself. + * + * A primary key is global, so an authored `id` names exactly ONE row in the + * whole database. A per-organization replay writes every seed row once per + * organization, so from the second organization on, the authored id is + * already taken. The replay then gives the row this id instead. + * + * It is DERIVED, not minted, and that is load-bearing. The replay keys each + * row by its `externalId` within the organization, and a dataset whose + * `externalId` is `id` (the showcase's `sys_business_unit` tree) has no + * other column to find its own row by. A random id would make every replay + * into the same organization insert the set again; a derived one is found + * again by the same lookup, so the replay stays idempotent. Deriving from the + * organization id itself rather than from a hash of it means two + * organizations can never be given the same id. + */ +function perOrganizationSeedRowId(authoredId: string, organizationId: string): string { + return `${authoredId}__${organizationId}`; +} + /** * SeedLoaderService — Runtime implementation of ISeedLoaderService * @@ -369,6 +392,11 @@ function localeScopeLabel(dataset: Seed): string { * - Idempotent replay: an upsert/update whose declared fields already match * the existing row is skipped (no update_at churn, no re-validation) — * seeds replay on every dev-server boot and package re-publish + * - Per-organization row identity (#21665): on a per-organization replay + * (`config.organizationId`), a row whose authored `id` another organization + * already holds gets an id of its own for this organization, and every + * reference in the same replay that names the authored id follows it — see + * {@link assignReplayRowIds} * - Actionable error reporting * * Replay safety invariant: a reference that cannot be resolved is NEVER @@ -463,6 +491,29 @@ export class SeedLoaderService implements ISeedLoaderService { * nothing about `required` there and stays silent rather than guessing. */ private requiredOnInsertByObject = new Map>(); + /** + * [#21665] Per seeded object, authored seed `id` → the id that row LANDED + * with in this load, for every row a per-organization replay could not give + * its authored id (see {@link assignReplayRowIds}). + * + * This is what re-points the replay's own references. A sibling row that + * names `parent_business_unit_id: 'bu_acme'` means "the row this seed + * authored as `bu_acme`", and in the second organization that row is not + * called `bu_acme`. {@link resolveReferenceItem} reads this map first, so + * such a reference resolves to this organization's row, in pass 1 and in + * pass 2 alike, whatever shape the authored id has. + * + * Only rows the replay actually re-identified are entered, and only once + * they have landed. A replay that keeps every authored id (the first + * organization's) enters nothing and resolves exactly as it did before; a + * row whose write failed is never entered, so a reference to it is reported + * unresolved instead of pointing at a row that does not exist. + * + * An instance field for the same reason {@link pointerRefsByObject} is one: + * pass 2 reads it several parameters away from where pass 1 fills it. + * Reset per `load`. + */ + private replayIdByAuthoredId = new Map>(); constructor(engine: IDataEngine, metadata: IMetadataService, logger: Logger) { this.engine = engine; @@ -560,6 +611,9 @@ export class SeedLoaderService implements ISeedLoaderService { // [#9071] Same per-load lifetime, same reason: a publish between two loads // can add (or drop) the `name` column this memo answers about. this.declaresNameColumnCache.clear(); + // [#21665] One replay's ids belong to one organization: a reused service + // instance must never re-point the next load's references with them. + this.replayIdByAuthoredId.clear(); // When the caller pinned no target org (an in-process publish has no active // user session — the AI build agent's publish path), BUSINESS seed rows @@ -865,6 +919,28 @@ export class SeedLoaderService implements ISeedLoaderService { ); } + // [#21665] Per-organization row identity. On a per-organization replay, + // decide which id each row authored with an `id` holds in THIS + // organization before anything is written: the authored id while no other + // row holds it, else this organization's own derived id. Dry runs write + // nothing and never probe the database, so they keep the authored ids. + const replayIds = config.organizationId && !config.dryRun + ? await this.assignReplayRowIds(objectName, dataset.records, config.organizationId) + : undefined; + /** Record index → the authored id that row was re-identified FROM. */ + const authoredIdByRecordIndex = new Map(); + if (replayIds) { + const reidentified = [...replayIds].filter(([authored, assigned]) => authored !== assigned).length; + if (reidentified > 0) { + this.logger.info( + `[SeedLoader] ${reidentified} ${objectName} seed row(s) carry an authored id a row outside organization ` + + `${config.organizationId} already holds; this organization gets ids of its own for them, and references ` + + `in this replay that name the authored ids follow them.`, + { object: objectName, organizationId: config.organizationId, reidentified }, + ); + } + } + // Get reference resolutions for this object const objectRefs = refMap.get(objectName) || []; @@ -901,6 +977,24 @@ export class SeedLoaderService implements ISeedLoaderService { // dropped the link ("empty externalId, so no internal id"). const deferredStart = deferredUpdates.length; const internalIdByRecordIndex = new Map(); + /** + * Record the id row `recordIndex` landed with. Every write site below goes + * through here, so a re-identified row (#21665) enters + * {@link SeedLoaderService.replayIdByAuthoredId} exactly when it lands and + * never before: a later row of this dataset, a later dataset and pass 2 + * then resolve its authored id to the row that is really there. + */ + const noteLanded = (recordIndex: number, landedId: string): void => { + internalIdByRecordIndex.set(recordIndex, landedId); // [commit 9a884c6e4] + const authored = authoredIdByRecordIndex.get(recordIndex); + if (authored === undefined) return; + let byAuthored = this.replayIdByAuthoredId.get(objectName); + if (!byAuthored) { + byAuthored = new Map(); + this.replayIdByAuthoredId.set(objectName, byAuthored); + } + byAuthored.set(authored, landedId); + }; const extIdOf = (rec: Record) => this.externalIdKey(rec, externalId); // bulkWrite is at-least-once: a retry (or a mismatch-driven degradation) // may re-run a write whose prior attempt already committed. Guard against @@ -990,7 +1084,7 @@ export class SeedLoaderService implements ISeedLoaderService { if (res.ok) { inserted++; const internalId = this.extractId(res.record); - if (internalId) internalIdByRecordIndex.set(recordIndex, internalId); // [commit 9a884c6e4] + if (internalId) noteLanded(recordIndex, internalId); // [commit 9a884c6e4] if (externalIdValue && internalId) { insertedRecords.get(objectName)!.set(externalIdValue, internalId); } @@ -1122,6 +1216,17 @@ export class SeedLoaderService implements ISeedLoaderService { stampedTenantOrg = true; } + // [#21665] Give the row the id it holds in THIS organization (decided + // above, before the loop). Written onto the record before the write + // decision, so a dataset keyed on `id` looks its own row up under that + // id: the next replay into this organization finds and skips it. + const authoredId = typeof record['id'] === 'string' ? (record['id'] as string) : undefined; + const replayId = authoredId !== undefined ? replayIds?.get(authoredId) : undefined; + if (authoredId !== undefined && replayId !== undefined && replayId !== authoredId) { + record['id'] = replayId; + authoredIdByRecordIndex.set(i, authoredId); + } + // Resolve references let unresolvedRefError = false; @@ -1476,7 +1581,7 @@ export class SeedLoaderService implements ISeedLoaderService { const externalIdValue = this.externalIdKey(record, externalId); const internalId = result.id; - if (internalId) internalIdByRecordIndex.set(i, String(internalId)); // [commit 9a884c6e4] + if (internalId) noteLanded(i, String(internalId)); // [commit 9a884c6e4] if (externalIdValue && internalId) { insertedRecords.get(objectName)!.set(externalIdValue, String(internalId)); } @@ -1487,7 +1592,7 @@ export class SeedLoaderService implements ISeedLoaderService { // mapping alive for downstream reference resolution. const externalIdValue = this.externalIdKey(record, externalId); const existingId = this.extractId(existingRecords?.get(externalIdValue)); - if (existingId) internalIdByRecordIndex.set(i, existingId); // [commit 9a884c6e4] + if (existingId) noteLanded(i, existingId); // [commit 9a884c6e4] if (externalIdValue && existingId) { insertedRecords.get(objectName)!.set(externalIdValue, existingId); } @@ -1510,7 +1615,7 @@ export class SeedLoaderService implements ISeedLoaderService { if (decision.action === 'skip') { skipped++; - if (decision.id) internalIdByRecordIndex.set(i, decision.id); // [commit 9a884c6e4] + if (decision.id) noteLanded(i, decision.id); // [commit 9a884c6e4] if (decision.id && externalIdValue) { insertedRecords.get(objectName)!.set(externalIdValue, decision.id); } @@ -1522,7 +1627,7 @@ export class SeedLoaderService implements ISeedLoaderService { // sever downstream natural-key resolution — that cascade is what // turned one legitimate validation error into NULLed-out child // references on every dev-server restart. - if (decision.id) internalIdByRecordIndex.set(i, decision.id); // [commit 9a884c6e4] same rationale + if (decision.id) noteLanded(i, decision.id); // [commit 9a884c6e4] same rationale if (externalIdValue) { insertedRecords.get(objectName)!.set(externalIdValue, decision.id); } @@ -1703,6 +1808,15 @@ export class SeedLoaderService implements ISeedLoaderService { }; } + // [#21665] An authored id of a row this replay re-identified names that + // row, in THIS organization. Asked BEFORE the internal-id short-circuit + // below on purpose: a UUID-shaped authored id would otherwise be kept + // verbatim and link this organization's row to another organization's. + if (typeof value === 'string') { + const replayed = this.replayIdByAuthoredId.get(ref.targetObject)?.get(value); + if (replayed !== undefined) return { status: 'resolved', value: replayed }; + } + // Not a natural key (an internal id, or a non-string the engine will // reject on its own terms) — keep it verbatim. if (typeof value !== 'string' || this.looksLikeInternalId(value)) { @@ -2736,6 +2850,85 @@ export class SeedLoaderService implements ISeedLoaderService { return map; } + /** + * [#21665] Decide, for every row of a dataset authored with an `id`, which + * id that row holds in `organizationId`. Returns authored id → assigned id, + * or `undefined` when no row of the dataset authors an id. + * + * A seeded row on a tenant-scoped object belongs to the organization the + * replay writes it for (ADR-0131: a seeded business unit is the + * organization's business unit), and the seed contract keys rows by + * `externalId` within that organization. An authored `id` is therefore the + * row's identity INSIDE one organization, never across them. A primary key + * is global, though, so the replay assigns: + * + * 1. the id this organization's row already has — the authored id or the + * derived one, whichever it finds — so a replay into the same + * organization matches its own row instead of inserting another; + * 2. otherwise the authored id, while no row anywhere holds it. The first + * organization a seed is replayed into keeps exactly the ids the seed + * authored, byte for byte what it got before this rule existed; + * 3. otherwise {@link perOrganizationSeedRowId}: the authored id is + * another organization's row (or an organization-less one), and is + * never reused. + * + * Two reads, both under the system context like every other seed read: this + * organization's own ids (the same scoped read the upsert pre-load does), + * and an unscoped probe per authored id this organization does not hold yet. + * Rows without an authored `id` are untouched: the engine mints theirs. + */ + private async assignReplayRowIds( + objectName: string, + records: ReadonlyArray>, + organizationId: string, + ): Promise | undefined> { + const authoredIds = new Set(); + for (const record of records) { + const id = record['id']; + if (typeof id === 'string' && id.length > 0) authoredIds.add(id); + } + if (authoredIds.size === 0) return undefined; + + const heldHere = await this.loadExistingRecords(objectName, 'id', organizationId); + const assigned = new Map(); + for (const authoredId of authoredIds) { + const derivedId = perOrganizationSeedRowId(authoredId, organizationId); + if (heldHere.has(authoredId)) assigned.set(authoredId, authoredId); + else if (heldHere.has(derivedId)) assigned.set(authoredId, derivedId); + else assigned.set(authoredId, (await this.isRowIdHeld(objectName, authoredId)) ? derivedId : authoredId); + } + return assigned; + } + + /** + * [#21665] Does ANY row of `objectName` hold `id`, in any organization or + * none? The question {@link assignReplayRowIds} must answer before it may + * hand a replayed row its authored id. + * + * A read that FAILED is not a "no": answering "free" would hand the replay + * an id that may well be taken, and the insert would collide, which is the + * defect this rule removes. So only the benign cause is absorbed — the + * object's table is not provisioned yet, which holds no row by definition — + * asked through the shared `isMissingTableError` predicate like + * {@link loadExistingRecords}; everything else propagates with its envelope + * intact. + */ + private async isRowIdHeld(objectName: string, id: string): Promise { + try { + const probe: EngineQueryOptions = { + where: { id }, + fields: ['id'], + limit: 1, + context: { isSystem: true }, + }; + const rows = await this.engine.find(objectName, probe); + return Array.isArray(rows) && rows.length > 0; + } catch (error) { + if (!isMissingTableError(error, objectName)) throw error; + return false; + } + } + private async loadExistingRecords( objectName: string, externalId: string | string[], diff --git a/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts new file mode 100644 index 00000000000..8e425f7edbc --- /dev/null +++ b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21665 — a per-organization seed replay gives each organization its own row + * identity. A fixed `id` authored in a seed is never reused across + * organizations, and in-replay references follow the id each organization got. + * + * ## The defect, as measured on a walled showcase boot + * + * Under a walled posture AppPlugin writes no seed rows at boot. It registers a + * `seed-replayer`, which the organizations runtime calls with the new + * organization's id on every `sys_organization` insert. The showcase seeds its + * `sys_business_unit` tree with authored ids (`bu_acme`, `bu_field_ops`, …) and + * `externalId: 'id'`. The first organization's replay inserted those ids + * verbatim. The second organization's replay looked for existing rows inside + * ITS organization only, found none, and inserted the same five ids again. A + * primary key is global, so all five were refused as duplicates. The four + * `parent_business_unit_id` references were then deferred, and pass 2 could not + * resolve them either: nine `[SeedLoader]` errors per organization, and every + * organization after the first started without the tree. + * + * ## Why this file runs real implementations + * + * The collision lives in the database's primary key. An engine double has to be + * told to refuse a duplicate id, so it would encode the very fact under test. + * This file uses the REAL replayer that AppPlugin registers on a walled boot + * (the callable the organizations runtime invokes per new organization), the + * real `SeedLoaderService` behind it, a real `ObjectQL` engine, and a real + * `SqlDriver` over better-sqlite3. It asserts on the stored rows. + * + * The walled boot is built here, in-test: a `tenancy` service answering the + * `isolated` posture is what makes AppPlugin skip the inline seed and register + * the replayer, the same branch a real walled deployment takes. No example app + * declares `@objectstack/organizations`, so a booted walled example is not + * available as a fixture; the replayer it would call is. + * + * ## The three pins, and the census row they are written against + * + * The census of fixed-id rows on tenant-scoped objects found exactly one + * population: the showcase's five `sys_business_unit` rows + * (`examples/app-showcase/src/data/seed/index.ts`). hotcrm and the platform's + * own packages seed none. `ORG_UNITS` below is that dataset's shape. + * + * 1. Two organizations created on a walled boot each hold the full seeded + * set, with zero `[SeedLoader]` errors. + * 2. The parent references resolve inside each organization. + * 3. The first organization is unchanged: it keeps the authored ids, and the + * second organization's replay does not touch its rows. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysBusinessUnit } from '@objectstack/platform-objects/identity'; +import type { PluginContext } from '@objectstack/core'; +import { AppPlugin } from './app-plugin'; + +/** better-auth-shaped organization ids — the shape every real organization has. */ +const ORG_NORTH = 'org_msnorthd2a7k3x9qf'; +const ORG_SOUTH = 'org_mssouthd2p4w8m2zc'; + +/** The census row: the showcase's `sys_business_unit` dataset, as authored. */ +const ORG_UNITS = { + object: 'sys_business_unit', + mode: 'upsert', + externalId: 'id', + records: [ + { id: 'bu_acme', name: 'Acme Corporation', code: 'ACME', kind: 'company', active: true }, + { id: 'bu_field_ops', name: 'Field Operations', code: 'FOPS', kind: 'division', parent_business_unit_id: 'bu_acme', active: true }, + { id: 'bu_west_coast', name: 'West Coast', code: 'FOPS-W', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true }, + { id: 'bu_east_coast', name: 'East Coast', code: 'FOPS-E', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true }, + { id: 'bu_hq_finance', name: 'HQ Finance', code: 'FIN', kind: 'department', parent_business_unit_id: 'bu_acme', active: true }, + ], +}; + +/** Authored name → authored parent name, the tree every organization must hold. */ +const AUTHORED_PARENT_BY_NAME: Record = { + 'Acme Corporation': null, + 'Field Operations': 'Acme Corporation', + 'West Coast': 'Field Operations', + 'East Coast': 'Field Operations', + 'HQ Finance': 'Acme Corporation', +}; + +/** + * The same tree authored with UUID-shaped ids. The loader treats a UUID-shaped + * reference value as an internal id and keeps it verbatim, so without the + * per-organization identity a second organization's parent link would point at + * the FIRST organization's row: a cross-organization reference. + */ +const UUID_ROOT = '6f1c2a7e-3b4d-4e5f-8a9b-0c1d2e3f4a5b'; +const UUID_CHILD = '9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c6b'; +const UUID_UNITS = { + object: 'sys_business_unit', + mode: 'upsert', + externalId: 'id', + records: [ + { id: UUID_ROOT, name: 'Root Unit', code: 'ROOT', kind: 'company', active: true }, + { id: UUID_CHILD, name: 'Child Unit', code: 'CHILD', kind: 'office', parent_business_unit_id: UUID_ROOT, active: true }, + ], +}; + +type Replayer = (organizationId: string) => Promise<{ inserted: number; updated: number; skipped: number; errors: unknown[] }>; +type Row = Record & { id: string; name: string; parent_business_unit_id?: string | null }; + +const openDrivers: SqlDriver[] = []; +const envBefore = process.env.OS_INLINE_SEED_BUDGET_MS; + +afterEach(async () => { + while (openDrivers.length) { + const d = openDrivers.pop(); + try { + await d?.disconnect(); + } catch { + /* a test that already disconnected is not a failure */ + } + } + if (envBefore === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS; + else process.env.OS_INLINE_SEED_BUDGET_MS = envBefore; +}); + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +/** + * Boot the walled composition and hand back the replayer AppPlugin registered. + * The replayer is the production closure: it builds the per-organization + * request (`defaultMode: 'upsert'`, `multiPass: true`, `organizationId`) and + * runs the real loader against the real engine. + */ +async function bootWalled(dataset: typeof ORG_UNITS) { + process.env.OS_INLINE_SEED_BUDGET_MS = '60000'; + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + openDrivers.push(driver); + await driver.initObjects([SysBusinessUnit as any]); + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(SysBusinessUnit as any, '@objectstack/platform-objects'); + + const metadata = { + getObject: vi.fn(async (name: string) => (name === 'sys_business_unit' ? SysBusinessUnit : undefined)), + listObjects: vi.fn(async () => [SysBusinessUnit]), + getObjects: vi.fn(async () => [SysBusinessUnit]), + get: vi.fn(async () => undefined), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + register: vi.fn(async () => {}), + unregister: vi.fn(async () => {}), + }; + const logger = createLogger(); + const services = new Map(); + // The walled posture. AppPlugin reads it through the `tenancy` service and, + // seeing a wall, writes nothing inline and registers the per-org replayer. + services.set('tenancy', { posture: 'isolated' }); + const ctx = { + logger, + registerService: vi.fn((name: string, svc: unknown) => { services.set(name, svc); }), + getService: vi.fn((name: string) => { + if (name === 'objectql') return engine; + if (name === 'metadata') return metadata; + return services.get(name); + }), + getServices: vi.fn(() => services), + hook: vi.fn(), + trigger: vi.fn(), + } as unknown as PluginContext; + + await new AppPlugin({ id: 'com.example.walled-seed-replay', data: [dataset] }).start(ctx); + + const replayer = services.get('seed-replayer') as Replayer | undefined; + if (typeof replayer !== 'function') throw new Error('the walled boot registered no seed-replayer'); + + const rowsOf = async (organizationId: string): Promise => { + const rows = (await engine.find('sys_business_unit', { where: { organization_id: organizationId } })) as Row[]; + return [...rows].sort((a, b) => a.name.localeCompare(b.name)); + }; + const seedLoaderErrors = () => + logger.error.mock.calls.filter((call) => String(call[0]).includes('[SeedLoader]')).map((call) => String(call[0])); + + return { engine, replayer, rowsOf, seedLoaderErrors }; +} + +/** Each row's parent, read back by NAME within the same organization's rows. */ +function parentNamesWithin(rows: Row[]): Record { + const byId = new Map(rows.map((r) => [r.id, r])); + const out: Record = {}; + for (const row of rows) { + const parentId = row.parent_business_unit_id ?? null; + out[row.name] = parentId === null ? null : (byId.get(parentId)?.name ?? `UNRESOLVED IN THIS ORG: ${parentId}`); + } + return out; +} + +describe('#21665 per-organization seed replay — row identity per organization (real replayer, ObjectQL, SqlDriver)', () => { + it('the walled boot writes nothing inline: every row below is a per-organization replay', async () => { + const { engine } = await bootWalled(ORG_UNITS); + expect(await engine.find('sys_business_unit', { where: {} })).toEqual([]); + }); + + it('pin 1 — two organizations each hold the full seeded set, with zero SeedLoader errors', async () => { + const { replayer, rowsOf, seedLoaderErrors } = await bootWalled(ORG_UNITS); + + const north = await replayer(ORG_NORTH); + const south = await replayer(ORG_SOUTH); + + expect(north.errors).toEqual([]); + expect(south.errors).toEqual([]); + expect(seedLoaderErrors()).toEqual([]); + expect(north.inserted).toBe(5); + expect(south.inserted).toBe(5); + + const authoredNames = ORG_UNITS.records.map((r) => r.name).sort(); + expect((await rowsOf(ORG_NORTH)).map((r) => r.name).sort()).toEqual(authoredNames); + expect((await rowsOf(ORG_SOUTH)).map((r) => r.name).sort()).toEqual(authoredNames); + }); + + it('pin 2 — the parent references resolve inside each organization', async () => { + const { replayer, rowsOf } = await bootWalled(ORG_UNITS); + await replayer(ORG_NORTH); + await replayer(ORG_SOUTH); + + const northRows = await rowsOf(ORG_NORTH); + const southRows = await rowsOf(ORG_SOUTH); + + expect(parentNamesWithin(northRows)).toEqual(AUTHORED_PARENT_BY_NAME); + expect(parentNamesWithin(southRows)).toEqual(AUTHORED_PARENT_BY_NAME); + + // No south row holds a north id, and no south parent link names one. + const northIds = new Set(northRows.map((r) => r.id)); + expect(southRows.filter((r) => northIds.has(r.id))).toEqual([]); + expect(southRows.filter((r) => r.parent_business_unit_id && northIds.has(r.parent_business_unit_id))).toEqual([]); + }); + + it('pin 3 — the first organization is unchanged: authored ids kept, untouched by the second replay', async () => { + const { replayer, rowsOf } = await bootWalled(ORG_UNITS); + await replayer(ORG_NORTH); + const northBefore = await rowsOf(ORG_NORTH); + + // The first organization holds exactly what the authored seed says, + // ids and parent links included: the shape it had before this change. + expect(northBefore.map((r) => [r.name, r.id, r.parent_business_unit_id ?? null])).toEqual( + ORG_UNITS.records + .map((r) => [r.name, r.id, (r as { parent_business_unit_id?: string }).parent_business_unit_id ?? null]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), + ); + + await replayer(ORG_SOUTH); + await replayer(ORG_SOUTH); + + expect(await rowsOf(ORG_NORTH)).toEqual(northBefore); + }); + + it('a second replay into the same organization finds its own rows: no duplicates, ids stable', async () => { + const { replayer, rowsOf } = await bootWalled(ORG_UNITS); + await replayer(ORG_NORTH); + await replayer(ORG_SOUTH); + const southFirst = await rowsOf(ORG_SOUTH); + + const again = await replayer(ORG_SOUTH); + + expect(again.errors).toEqual([]); + expect(again.inserted).toBe(0); + expect(await rowsOf(ORG_SOUTH)).toEqual(southFirst); + }); + + it('a UUID-shaped authored id is re-pointed too: the second organization never links to the first one\'s row', async () => { + const { replayer, rowsOf, seedLoaderErrors } = await bootWalled(UUID_UNITS); + await replayer(ORG_NORTH); + const south = await replayer(ORG_SOUTH); + + expect(south.errors).toEqual([]); + expect(seedLoaderErrors()).toEqual([]); + + const northRows = await rowsOf(ORG_NORTH); + const southRows = await rowsOf(ORG_SOUTH); + expect(northRows.map((r) => r.id).sort()).toEqual([UUID_ROOT, UUID_CHILD].sort()); + expect(parentNamesWithin(southRows)).toEqual({ 'Child Unit': 'Root Unit', 'Root Unit': null }); + const southChild = southRows.find((r) => r.name === 'Child Unit'); + expect(southChild?.parent_business_unit_id).not.toBe(UUID_ROOT); + }); +});