From 2aaabcfaf51608b367c365edc219e37a0e9bb3ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:42:01 +0000 Subject: [PATCH 1/4] test(runtime): pin per-organization row identity on a walled seed replay Real replayer (AppPlugin on a walled posture), real SeedLoaderService, ObjectQL and SqlDriver over better-sqlite3: two organizations each hold the full sys_business_unit set with zero SeedLoader errors, parent links resolve inside each organization, and the first organization keeps its authored ids. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...-organization-identity.integration.test.ts | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts 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); + }); +}); From 2a984c9878074e0b7e95b2755c2ef3e3829ac125 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:45:51 +0000 Subject: [PATCH 2/4] fix(metadata-protocol): give each organization its own row identity on a per-organization seed replay A per-organization replay re-inserted every authored seed id verbatim, so from the second organization on each fixed-id row collided on the global primary key and the references into those rows stayed unresolved. The replay now keeps the authored id while no row holds it (the first organization is unchanged), otherwise gives the row an id derived from the organization, and re-points this replay's references that name the authored id to the id the row landed with. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 201 +++++++++++++++++- 1 file changed, 196 insertions(+), 5 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 1af7535c841..42cac258d45 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -347,6 +347,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 +391,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 +490,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 +610,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 +918,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 +976,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 +1083,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 +1215,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 +1580,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 +1591,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 +1614,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 +1626,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 +1807,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 +2849,84 @@ 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 rows = await this.engine.find(objectName, { + where: { id }, + fields: ['id'], + limit: 1, + context: { isSystem: true }, + } as any); + 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[], From a5eee6d05746329d50f04838c93cf2da0492e64a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:54:51 +0000 Subject: [PATCH 3/4] chore(changeset): patch metadata-protocol for per-organization seed replay identity Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../21665-seed-replay-per-organization-ids.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/21665-seed-replay-per-organization-ids.md 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. From 73d2c4fdb48dbdde7666ae7dd4f2ef792c4f86fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:17:41 +0000 Subject: [PATCH 4/4] fix(metadata-protocol): type the replay id probe's query options Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 42cac258d45..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'; @@ -2914,12 +2915,13 @@ export class SeedLoaderService implements ISeedLoaderService { */ private async isRowIdHeld(objectName: string, id: string): Promise { try { - const rows = await this.engine.find(objectName, { + const probe: EngineQueryOptions = { where: { id }, fields: ['id'], limit: 1, context: { isSystem: true }, - } as any); + }; + const rows = await this.engine.find(objectName, probe); return Array.isArray(rows) && rows.length > 0; } catch (error) { if (!isMissingTableError(error, objectName)) throw error;