Skip to content

Commit ff16740

Browse files
fix(metadata-protocol): give each organization its own row identity on a per-organization seed replay (#21688)
Fixes #21665 Clause-②: no ## What this changes On a walled deployment, every organization created after the first used to start without the app's fixed-id seed rows. A per-organization replay re-inserted each authored `id` verbatim, a primary key is global, so the second organization's inserts were refused as duplicates. The references into those rows then stayed unresolved: 9 `[SeedLoader]` errors per organization for the showcase's `sys_business_unit` tree. `SeedLoaderService` (`packages/metadata-protocol/src/seed-loader.ts`) now gives each organization its own row identity on a per-organization load (`config.organizationId`): 1. A row authored with an `id` gets the id **this organization's row already has**, whether that is the authored id or the derived one. A second replay into the same organization therefore finds its own row and does not insert another. 2. Otherwise it gets the **authored id, while no row anywhere holds it**. The first organization a seed is replayed into keeps exactly the authored ids. 3. Otherwise it gets an id **derived from the authored id and the organization**. The authored id belongs to another organization's row (or an organization-less one) and is never reused. 4. A reference in the same replay that names an authored id of a re-identified row resolves to the id that row **landed** with, in pass 1 and in pass 2. The lookup runs before the internal-id short-circuit, so a UUID-shaped authored id is re-pointed too. Before, it was kept verbatim and would have linked the row to another organization's row. Producer location: as the claim expected, the replayer itself. No showcase seed change was needed, and there is no second copy of the seeds per organization. The rule sits in `load()`, 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` take the code path they took before. ## Census (triage: census first) | Tree | Fixed-id rows on tenant-scoped objects | References into them | |:---|:---|:---| | `examples/**`, objectstack at `72f3c74d60` (re-read at merge base `7d0781482d`) | **1 population**: `examples/app-showcase/src/data/seed/index.ts`, `sys_business_unit` (tenant-scoped: `scripts/platform-object-tenancy-census.json` reach `in`, tenant field `organization_id`), 5 rows `bu_acme`, `bu_field_ops`, `bu_west_coast`, `bu_east_coast`, `bu_hq_finance`, `externalId: 'id'`. app-crm, app-todo, app-multi-package, embed-objectql: 0 | **In-replay (re-pointed by this PR):** 4 `parent_business_unit_id` links in the same dataset. **Declared metadata, outside the replay:** `examples/app-showcase/src/security/sharing-rules.ts:78`, `sharedWith: { type: 'unit_and_subordinates', value: 'bu_field_ops' }` (see Acceptance notes). **By name, not id:** `permission-sets.ts:401` `adminScope.businessUnit: 'Field Operations'`. **Comments only:** `approver-bindings.flow.ts:85`, `permission-sets.ts:379`. **Tests on the single posture (path unchanged):** `packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts:63,82-85`, `showcase-declarative-rbac-seeding.dogfood.test.ts:42` | | hotcrm at `4054ec2680` (shallow read-only clone, deleted after) | **0**. All 9 seed files (`src/*/data/*.seed.ts`) key by natural or composite keys, and there are zero `id:` keys in seed records | none | | The platform's own packages (`packages/**`, non-test) | **0**. No `SeedSchema` dataset ships from a platform package | none | ## Stop condition (ADR-0131 and the seed contract): not met Neither source declares a fixed seed id as a cross-organization identity. Both say the opposite: - ADR-0131 §D3: "a seeded `sys_business_unit` is the organization's business unit". The group-posture list, item 12, says "Seeds under `group` must name their organization". No clause makes a seed row id span organizations. - `packages/spec/src/data/seed.zod.ts` (`externalId`): "Standard: 'id' is rarely used for portable seed data — prefer natural keys." - `packages/spec/src/data/seed-loader.zod.ts` (`organizationId`): per-tenant replay gives "every new tenant a private copy", and the scoped lookup exists "so `upsert` mode finds the per-org copy rather than another tenant's row". - Published guidance, `skills/objectstack-data/references/seeds.md:70`: "**Never use `id`**". The only text that treats `bu_field_ops` as a static, cross-organization handle is the showcase's own comment on its sharing rule. That is app convention, not ADR or contract. ## Mechanism hypotheses, as measured - **H1, confirmed.** At `72f3c74d60`, an authored `id` went to `engine.insert` verbatim. The existing-row pre-load is scoped to the organization, so the second organization saw no row and inserted the same ids. On a real `SqlDriver` the result was `UNIQUE constraint failed: sys_business_unit.id`, with exactly 9 errors in the second organization's replay. - **H2, confirmed, with one refinement.** References are raw authored ids (`parent_business_unit_id: 'bu_acme'`), and the dataset's `externalId` *is* `id`. "Keyed by externalId within the organization" therefore needs an id the replay can find again. A minted random id would make every later replay into that organization insert the set again. So the assigned id is **derived** (authored id + `__` + organization id). It is deterministic per organization, and for one authored id two organizations never get the same id. The replay stays idempotent. Re-pointing covers only rows this replay re-identified, and only once they landed. - **H3: the first organization keeps the authored ids.** That is what keeps it byte-identical. It is also what keeps the showcase's sharing rule, which names `bu_field_ops` by row id, working where it works today. If every organization got derived ids, a fresh walled boot's first organization would differ from today's, and that rule would resolve nowhere at all. - **H4.** See the census above. - **H5.** See the stop condition above. - **H6.** The walled boot is built in-test. A `tenancy` service answering `isolated` makes AppPlugin skip the inline seed and register its real `seed-replayer`, which the organizations runtime calls per new organization. No repeatable fixture beyond the test is needed, so no fixture card is asked for. ## Pins `packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts` uses the real replayer from AppPlugin on a walled posture, the real `SeedLoaderService`, `ObjectQL`, and `SqlDriver` over better-sqlite3, and asserts on stored rows: 1. Two organizations each hold the full seeded set, with zero `[SeedLoader]` errors. 2. The parent references resolve inside each organization, and no south row or link names a north id. 3. The first organization is unchanged. It keeps the authored ids and parent links, and its rows deep-equal their snapshot after the second organization's replay runs twice. - A second replay into the same organization inserts 0 rows, and its ids are stable. - A UUID-shaped authored id is re-pointed, so the second organization never links to the first one's row. None of these is a refusal pin (all are positive outcomes), so the ADR-0112 `code` + `status` clause has no subject here. ## Reverse verification (fix committed first) The fix was committed at `2a984c9878`, and the mutation ran from that committed state. `scripts/ablation-replace.mjs` replaced the anchor `const replayIds = config.organizationId && !config.dryRun` with a never-true guard carrying the marker `ABLATION_21665_FIXED_IDS_REUSED`, so fixed ids were reused. The anchor count went 1 → 0, and the blob changed `42cac258d456` → `1395cd65f51c`. Then the package was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - Mutated: `Tests 4 failed | 2 passed (6)`. The red ones were **pin 1**, **pin 2**, the re-replay case and the UUID case. The green ones were **pin 3** and the walled-inline precondition. The log held 32 `UNIQUE constraint failed: sys_business_unit.id` lines. - Restored with `git checkout HEAD -- packages/metadata-protocol/src/seed-loader.ts`. The blob matched HEAD (`42cac258d456`), `git diff HEAD` was 0 bytes, and `git status --porcelain` showed 0 lines. After a rebuild, `--absent` confirmed the marker was gone from all 24 built files. The rerun gave `Tests 6 passed (6)`. - First attempt, reported honestly: my first replacement spelling contained the anchor, so `ablation-replace` refused it (anchor count 1 → 1) and restored the file itself. Nothing ran on that attempt. ## Tests All readings below were taken at `73d2c4fdb4`, which is the final commit, after `origin/main` (`7d0781482d`) was merged in. - **Reproduced on the base first** (`72f3c74d60`, before any edit): pins 1 and 2, the re-replay case and the UUID case were red (`Tests 4 failed | 2 passed (6)`), and the log held `UNIQUE constraint failed: sys_business_unit.id`. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: `Test Files 209 passed | 3 skipped (212)`, `Tests 3463 passed | 19 skipped (3482)`. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. The test layer compiles under `tsconfig.test.json`, and `test-typecheck-debt.json` is held. - Runtime, the new pin file plus all 16 seed-related suites (`app-plugin.*seed*`, `seed-*`, `domains/packages-seed-apply-*`): `Test Files 17 passed (17)`, `Tests 159 passed (159)`. - Other suites that drive the real loader, run at `2a984c9878` (unchanged since, apart from a type annotation): objectql `seed-loader-org-fallback.test.ts` passed 5/5. cloud-connection `marketplace-install-local-{seed-replayer,heal,tenancy-posture}.test.ts` passed 30/30. - Scope: `turbo ls --affected` was not used. Consumers that import `@objectstack/metadata-protocol` take no public-surface change (no export, signature or schema moved), so their full suites are left to CI. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths), re-derived at this commit, gave **62 commands. All 62 exited 0.** `--ran` reconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN", with exit codes recorded. `check:dual-build-cjs-loads` needs every package built, so it ran after a full `turbo run build` ("106 published require entry point(s) across 66 package(s) load"). - **Artifact-roster block** (55 commands printed outside the total): **52 exited 0.** Three are PR-context gates that print NOT WIRED / NOT MEASURED without a pull request (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`). They are re-run against this PR and reported in the `os-dev-report` comment on the card. The roster includes `check:error-status-conformance` ("every derivable runtime status is documented, and every documented status is reachable") and `check:engine-double-contract` (OK, 936 pinned). - One real red was caught and fixed on the way: `check:query-options-erasure` saw the new id probe erase its query options (`as any`, 3 → 4 grandfathered sites). It is now a typed `EngineQueryOptions` local, and the ratchet "holds: 67 unswept non-test site(s) in 17 file(s), none new". ## Acceptance notes - **The declared sharing rule names a seeded row id.** `examples/app-showcase/src/security/sharing-rules.ts:78` uses `unit_and_subordinates` with `value: 'bu_field_ops'`. On a walled deployment it resolves only in the organization holding that id, which is the first one. Later organizations now hold their own Field Operations unit under a derived id. The rule does not reach it there. Reading the code, `warnOnEmptyUnitExpansion` is the branch that reports that empty expansion; I did not measure it firing. This is not a regression: before this PR those organizations had no units at all. It is a question about how declared, cross-tenant metadata should name organization-owned rows, and it is outside this card. Noted only (carrier: none). - **`driver-memory` lands a second row under an existing primary key.** I measured this directly on the driver: two `create('probe_obj', { id: 'fixed_1' })` calls gave 2 rows with that id, with no refusal. That is why this defect class is invisible on memory-driver suites, and why the pins use `SqlDriver`. No public-door reach was measured, so it is noted, not filed (carrier: none). - **Fixture gap** (no example declares `@objectstack/organizations`): this claim did not need a repeatable walled boot, so no fixture card is requested. - The deterministic id format is an implementation detail and is not pinned. The tests assert properties instead: it differs from the authored id, it is stable across replays, and it is unique per organization. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1db5322 commit ff16740

3 files changed

Lines changed: 501 additions & 5 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
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.
6+
7+
Clause-②: no
8+
9+
- 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.
10+
- 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.
11+
- 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.
12+
- The replay logs one `info` line per dataset that it re-identified.
13+
- 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.
14+
- Boot seeding without an organization, dry runs and rows without an authored `id` are unchanged.
15+
- ⛔ No schema, export, accepted input or error code changes.

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 198 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ReferenceResolutionError,
1313
SeedLoadResultParsed,
1414
Seed,
15+
EngineQueryOptions,
1516
} from '@objectstack/spec/data';
1617
import { SeedLoaderConfigSchema, isMultiValueField, referenceTargetOf } from '@objectstack/spec/data';
1718
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
@@ -347,6 +348,28 @@ function localeScopeLabel(dataset: Seed): string {
347348
return `${dataset.object} (locale: ${(dataset.locale as string[]).join(', ')})`;
348349
}
349350

351+
/**
352+
* [#21665] The id a seed row authored as `authoredId` holds in `organizationId`
353+
* when a per-organization replay cannot give it the authored id itself.
354+
*
355+
* A primary key is global, so an authored `id` names exactly ONE row in the
356+
* whole database. A per-organization replay writes every seed row once per
357+
* organization, so from the second organization on, the authored id is
358+
* already taken. The replay then gives the row this id instead.
359+
*
360+
* It is DERIVED, not minted, and that is load-bearing. The replay keys each
361+
* row by its `externalId` within the organization, and a dataset whose
362+
* `externalId` is `id` (the showcase's `sys_business_unit` tree) has no
363+
* other column to find its own row by. A random id would make every replay
364+
* into the same organization insert the set again; a derived one is found
365+
* again by the same lookup, so the replay stays idempotent. Deriving from the
366+
* organization id itself rather than from a hash of it means two
367+
* organizations can never be given the same id.
368+
*/
369+
function perOrganizationSeedRowId(authoredId: string, organizationId: string): string {
370+
return `${authoredId}__${organizationId}`;
371+
}
372+
350373
/**
351374
* SeedLoaderService — Runtime implementation of ISeedLoaderService
352375
*
@@ -369,6 +392,11 @@ function localeScopeLabel(dataset: Seed): string {
369392
* - Idempotent replay: an upsert/update whose declared fields already match
370393
* the existing row is skipped (no update_at churn, no re-validation) —
371394
* seeds replay on every dev-server boot and package re-publish
395+
* - Per-organization row identity (#21665): on a per-organization replay
396+
* (`config.organizationId`), a row whose authored `id` another organization
397+
* already holds gets an id of its own for this organization, and every
398+
* reference in the same replay that names the authored id follows it — see
399+
* {@link assignReplayRowIds}
372400
* - Actionable error reporting
373401
*
374402
* Replay safety invariant: a reference that cannot be resolved is NEVER
@@ -463,6 +491,29 @@ export class SeedLoaderService implements ISeedLoaderService {
463491
* nothing about `required` there and stays silent rather than guessing.
464492
*/
465493
private requiredOnInsertByObject = new Map<string, Set<string>>();
494+
/**
495+
* [#21665] Per seeded object, authored seed `id` → the id that row LANDED
496+
* with in this load, for every row a per-organization replay could not give
497+
* its authored id (see {@link assignReplayRowIds}).
498+
*
499+
* This is what re-points the replay's own references. A sibling row that
500+
* names `parent_business_unit_id: 'bu_acme'` means "the row this seed
501+
* authored as `bu_acme`", and in the second organization that row is not
502+
* called `bu_acme`. {@link resolveReferenceItem} reads this map first, so
503+
* such a reference resolves to this organization's row, in pass 1 and in
504+
* pass 2 alike, whatever shape the authored id has.
505+
*
506+
* Only rows the replay actually re-identified are entered, and only once
507+
* they have landed. A replay that keeps every authored id (the first
508+
* organization's) enters nothing and resolves exactly as it did before; a
509+
* row whose write failed is never entered, so a reference to it is reported
510+
* unresolved instead of pointing at a row that does not exist.
511+
*
512+
* An instance field for the same reason {@link pointerRefsByObject} is one:
513+
* pass 2 reads it several parameters away from where pass 1 fills it.
514+
* Reset per `load`.
515+
*/
516+
private replayIdByAuthoredId = new Map<string, Map<string, string>>();
466517

467518
constructor(engine: IDataEngine, metadata: IMetadataService, logger: Logger) {
468519
this.engine = engine;
@@ -560,6 +611,9 @@ export class SeedLoaderService implements ISeedLoaderService {
560611
// [#9071] Same per-load lifetime, same reason: a publish between two loads
561612
// can add (or drop) the `name` column this memo answers about.
562613
this.declaresNameColumnCache.clear();
614+
// [#21665] One replay's ids belong to one organization: a reused service
615+
// instance must never re-point the next load's references with them.
616+
this.replayIdByAuthoredId.clear();
563617

564618
// When the caller pinned no target org (an in-process publish has no active
565619
// user session — the AI build agent's publish path), BUSINESS seed rows
@@ -865,6 +919,28 @@ export class SeedLoaderService implements ISeedLoaderService {
865919
);
866920
}
867921

922+
// [#21665] Per-organization row identity. On a per-organization replay,
923+
// decide which id each row authored with an `id` holds in THIS
924+
// organization before anything is written: the authored id while no other
925+
// row holds it, else this organization's own derived id. Dry runs write
926+
// nothing and never probe the database, so they keep the authored ids.
927+
const replayIds = config.organizationId && !config.dryRun
928+
? await this.assignReplayRowIds(objectName, dataset.records, config.organizationId)
929+
: undefined;
930+
/** Record index → the authored id that row was re-identified FROM. */
931+
const authoredIdByRecordIndex = new Map<number, string>();
932+
if (replayIds) {
933+
const reidentified = [...replayIds].filter(([authored, assigned]) => authored !== assigned).length;
934+
if (reidentified > 0) {
935+
this.logger.info(
936+
`[SeedLoader] ${reidentified} ${objectName} seed row(s) carry an authored id a row outside organization ` +
937+
`${config.organizationId} already holds; this organization gets ids of its own for them, and references ` +
938+
`in this replay that name the authored ids follow them.`,
939+
{ object: objectName, organizationId: config.organizationId, reidentified },
940+
);
941+
}
942+
}
943+
868944
// Get reference resolutions for this object
869945
const objectRefs = refMap.get(objectName) || [];
870946

@@ -901,6 +977,24 @@ export class SeedLoaderService implements ISeedLoaderService {
901977
// dropped the link ("empty externalId, so no internal id").
902978
const deferredStart = deferredUpdates.length;
903979
const internalIdByRecordIndex = new Map<number, string>();
980+
/**
981+
* Record the id row `recordIndex` landed with. Every write site below goes
982+
* through here, so a re-identified row (#21665) enters
983+
* {@link SeedLoaderService.replayIdByAuthoredId} exactly when it lands and
984+
* never before: a later row of this dataset, a later dataset and pass 2
985+
* then resolve its authored id to the row that is really there.
986+
*/
987+
const noteLanded = (recordIndex: number, landedId: string): void => {
988+
internalIdByRecordIndex.set(recordIndex, landedId); // [commit 9a884c6e4]
989+
const authored = authoredIdByRecordIndex.get(recordIndex);
990+
if (authored === undefined) return;
991+
let byAuthored = this.replayIdByAuthoredId.get(objectName);
992+
if (!byAuthored) {
993+
byAuthored = new Map();
994+
this.replayIdByAuthoredId.set(objectName, byAuthored);
995+
}
996+
byAuthored.set(authored, landedId);
997+
};
904998
const extIdOf = (rec: Record<string, unknown>) => this.externalIdKey(rec, externalId);
905999
// bulkWrite is at-least-once: a retry (or a mismatch-driven degradation)
9061000
// may re-run a write whose prior attempt already committed. Guard against
@@ -990,7 +1084,7 @@ export class SeedLoaderService implements ISeedLoaderService {
9901084
if (res.ok) {
9911085
inserted++;
9921086
const internalId = this.extractId(res.record);
993-
if (internalId) internalIdByRecordIndex.set(recordIndex, internalId); // [commit 9a884c6e4]
1087+
if (internalId) noteLanded(recordIndex, internalId); // [commit 9a884c6e4]
9941088
if (externalIdValue && internalId) {
9951089
insertedRecords.get(objectName)!.set(externalIdValue, internalId);
9961090
}
@@ -1122,6 +1216,17 @@ export class SeedLoaderService implements ISeedLoaderService {
11221216
stampedTenantOrg = true;
11231217
}
11241218

1219+
// [#21665] Give the row the id it holds in THIS organization (decided
1220+
// above, before the loop). Written onto the record before the write
1221+
// decision, so a dataset keyed on `id` looks its own row up under that
1222+
// id: the next replay into this organization finds and skips it.
1223+
const authoredId = typeof record['id'] === 'string' ? (record['id'] as string) : undefined;
1224+
const replayId = authoredId !== undefined ? replayIds?.get(authoredId) : undefined;
1225+
if (authoredId !== undefined && replayId !== undefined && replayId !== authoredId) {
1226+
record['id'] = replayId;
1227+
authoredIdByRecordIndex.set(i, authoredId);
1228+
}
1229+
11251230
// Resolve references
11261231
let unresolvedRefError = false;
11271232

@@ -1476,7 +1581,7 @@ export class SeedLoaderService implements ISeedLoaderService {
14761581

14771582
const externalIdValue = this.externalIdKey(record, externalId);
14781583
const internalId = result.id;
1479-
if (internalId) internalIdByRecordIndex.set(i, String(internalId)); // [commit 9a884c6e4]
1584+
if (internalId) noteLanded(i, String(internalId)); // [commit 9a884c6e4]
14801585
if (externalIdValue && internalId) {
14811586
insertedRecords.get(objectName)!.set(externalIdValue, String(internalId));
14821587
}
@@ -1487,7 +1592,7 @@ export class SeedLoaderService implements ISeedLoaderService {
14871592
// mapping alive for downstream reference resolution.
14881593
const externalIdValue = this.externalIdKey(record, externalId);
14891594
const existingId = this.extractId(existingRecords?.get(externalIdValue));
1490-
if (existingId) internalIdByRecordIndex.set(i, existingId); // [commit 9a884c6e4]
1595+
if (existingId) noteLanded(i, existingId); // [commit 9a884c6e4]
14911596
if (externalIdValue && existingId) {
14921597
insertedRecords.get(objectName)!.set(externalIdValue, existingId);
14931598
}
@@ -1510,7 +1615,7 @@ export class SeedLoaderService implements ISeedLoaderService {
15101615

15111616
if (decision.action === 'skip') {
15121617
skipped++;
1513-
if (decision.id) internalIdByRecordIndex.set(i, decision.id); // [commit 9a884c6e4]
1618+
if (decision.id) noteLanded(i, decision.id); // [commit 9a884c6e4]
15141619
if (decision.id && externalIdValue) {
15151620
insertedRecords.get(objectName)!.set(externalIdValue, decision.id);
15161621
}
@@ -1522,7 +1627,7 @@ export class SeedLoaderService implements ISeedLoaderService {
15221627
// sever downstream natural-key resolution — that cascade is what
15231628
// turned one legitimate validation error into NULLed-out child
15241629
// references on every dev-server restart.
1525-
if (decision.id) internalIdByRecordIndex.set(i, decision.id); // [commit 9a884c6e4] same rationale
1630+
if (decision.id) noteLanded(i, decision.id); // [commit 9a884c6e4] same rationale
15261631
if (externalIdValue) {
15271632
insertedRecords.get(objectName)!.set(externalIdValue, decision.id);
15281633
}
@@ -1703,6 +1808,15 @@ export class SeedLoaderService implements ISeedLoaderService {
17031808
};
17041809
}
17051810

1811+
// [#21665] An authored id of a row this replay re-identified names that
1812+
// row, in THIS organization. Asked BEFORE the internal-id short-circuit
1813+
// below on purpose: a UUID-shaped authored id would otherwise be kept
1814+
// verbatim and link this organization's row to another organization's.
1815+
if (typeof value === 'string') {
1816+
const replayed = this.replayIdByAuthoredId.get(ref.targetObject)?.get(value);
1817+
if (replayed !== undefined) return { status: 'resolved', value: replayed };
1818+
}
1819+
17061820
// Not a natural key (an internal id, or a non-string the engine will
17071821
// reject on its own terms) — keep it verbatim.
17081822
if (typeof value !== 'string' || this.looksLikeInternalId(value)) {
@@ -2736,6 +2850,85 @@ export class SeedLoaderService implements ISeedLoaderService {
27362850
return map;
27372851
}
27382852

2853+
/**
2854+
* [#21665] Decide, for every row of a dataset authored with an `id`, which
2855+
* id that row holds in `organizationId`. Returns authored id → assigned id,
2856+
* or `undefined` when no row of the dataset authors an id.
2857+
*
2858+
* A seeded row on a tenant-scoped object belongs to the organization the
2859+
* replay writes it for (ADR-0131: a seeded business unit is the
2860+
* organization's business unit), and the seed contract keys rows by
2861+
* `externalId` within that organization. An authored `id` is therefore the
2862+
* row's identity INSIDE one organization, never across them. A primary key
2863+
* is global, though, so the replay assigns:
2864+
*
2865+
* 1. the id this organization's row already has — the authored id or the
2866+
* derived one, whichever it finds — so a replay into the same
2867+
* organization matches its own row instead of inserting another;
2868+
* 2. otherwise the authored id, while no row anywhere holds it. The first
2869+
* organization a seed is replayed into keeps exactly the ids the seed
2870+
* authored, byte for byte what it got before this rule existed;
2871+
* 3. otherwise {@link perOrganizationSeedRowId}: the authored id is
2872+
* another organization's row (or an organization-less one), and is
2873+
* never reused.
2874+
*
2875+
* Two reads, both under the system context like every other seed read: this
2876+
* organization's own ids (the same scoped read the upsert pre-load does),
2877+
* and an unscoped probe per authored id this organization does not hold yet.
2878+
* Rows without an authored `id` are untouched: the engine mints theirs.
2879+
*/
2880+
private async assignReplayRowIds(
2881+
objectName: string,
2882+
records: ReadonlyArray<Record<string, unknown>>,
2883+
organizationId: string,
2884+
): Promise<Map<string, string> | undefined> {
2885+
const authoredIds = new Set<string>();
2886+
for (const record of records) {
2887+
const id = record['id'];
2888+
if (typeof id === 'string' && id.length > 0) authoredIds.add(id);
2889+
}
2890+
if (authoredIds.size === 0) return undefined;
2891+
2892+
const heldHere = await this.loadExistingRecords(objectName, 'id', organizationId);
2893+
const assigned = new Map<string, string>();
2894+
for (const authoredId of authoredIds) {
2895+
const derivedId = perOrganizationSeedRowId(authoredId, organizationId);
2896+
if (heldHere.has(authoredId)) assigned.set(authoredId, authoredId);
2897+
else if (heldHere.has(derivedId)) assigned.set(authoredId, derivedId);
2898+
else assigned.set(authoredId, (await this.isRowIdHeld(objectName, authoredId)) ? derivedId : authoredId);
2899+
}
2900+
return assigned;
2901+
}
2902+
2903+
/**
2904+
* [#21665] Does ANY row of `objectName` hold `id`, in any organization or
2905+
* none? The question {@link assignReplayRowIds} must answer before it may
2906+
* hand a replayed row its authored id.
2907+
*
2908+
* A read that FAILED is not a "no": answering "free" would hand the replay
2909+
* an id that may well be taken, and the insert would collide, which is the
2910+
* defect this rule removes. So only the benign cause is absorbed — the
2911+
* object's table is not provisioned yet, which holds no row by definition —
2912+
* asked through the shared `isMissingTableError` predicate like
2913+
* {@link loadExistingRecords}; everything else propagates with its envelope
2914+
* intact.
2915+
*/
2916+
private async isRowIdHeld(objectName: string, id: string): Promise<boolean> {
2917+
try {
2918+
const probe: EngineQueryOptions = {
2919+
where: { id },
2920+
fields: ['id'],
2921+
limit: 1,
2922+
context: { isSystem: true },
2923+
};
2924+
const rows = await this.engine.find(objectName, probe);
2925+
return Array.isArray(rows) && rows.length > 0;
2926+
} catch (error) {
2927+
if (!isMissingTableError(error, objectName)) throw error;
2928+
return false;
2929+
}
2930+
}
2931+
27392932
private async loadExistingRecords(
27402933
objectName: string,
27412934
externalId: string | string[],

0 commit comments

Comments
 (0)