Skip to content

Commit b1e9280

Browse files
fix(plugin-security,organizations): the seed-ownership claims re-own only the rows the seed landed on (ADR-0138 D1) (#23605)
Fixes #23354 Clause-②: yes ## What this changes The seed-ownership claim hands seeded business records to the platform admin. It runs on every `app:seeded`, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object. No stored column tells a seed row from a guest-written row or a public form's intake, and ADR-0138 D1 says those keep no owner ("the owner stays unset"). So the claim handed them to the platform admin at the next boot. This takes triage's route, option A (`6182011415`). The seed loader already records the row every record landed on. It now publishes those rows, and both claims re-own only rows that are both unowned and seeded. ### `@objectstack/spec` (minor) - `SeedLoadResultSchema` gains `landedIds` (`z.array(z.string()).default([])`, described). It holds the ids of the stored rows a dataset's records landed on: a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. `gen:schema` and `gen:docs` were regenerated: `authorable-surface/data.json`, `authorable-defaults/data.json` and `content/docs/references/data/seed-loader.mdx`. - `ISeedSettlementService` gains `seededRecordIds?(object, organizationId?)`. - **Without `organizationId`**, it answers for this process's boot-time seed sources. A source publishes its rows before it settles, so a reader inside `app:seeded` sees them. - **With `organizationId`**, it answers for that organization's per-organization replay. The host keeps the most recent replay only (see open question 1). - It is optional: a host that publishes no reading leaves the reader with no way to place an unowned row, and the reader then re-owns no NULL-owner row. ### `@objectstack/metadata-protocol` (minor) `SeedLoaderService.loadDataset` returns `landedIds` from `internalIdByRecordIndex`, the map every write site already fills through `noteLanded`. That covers the insert flush, the self-reference write, the update path, the replay skip path and a refused update of a matched row. No resolution rule is copied anywhere. ### `@objectstack/runtime` (minor) - **The tracker** (`seed-settlement.ts`) implements `seededRecordIds`. `SeedSourceHandle` gains `publishLanded(report)`, and the new module function `recordOrganizationReplay(ctx, organizationId, report)` records a per-organization replay. That function is not re-exported from the package entry. - **`AppPlugin`** publishes the inline seed's `result.results` before `seedSource.settle()`. The basic-insert fallback now keeps the ids of the rows it inserts and publishes them too. The per-organization `seed-replayer` records each replay under its organization. ### `@objectstack/plugin-security` (patch) - **The NULL arm.** `claimSeedOwnership` builds it from `options.seededRecordIds` as `owner_id IS NULL AND id IN (chunk)`, one write per chunk of `CLAIM_PAGE_ROWS` ids, which is half the per-row hook ceiling. A chunk is never refused for its size and stays inside a driver's parameter limit. - **The `usr_system` arm** is unchanged. No guest door, form door or runtime writer stamps that owner (`git grep SystemUserId.SYSTEM`), so only seed or legacy rows carry it. - **No reading.** Without `seededRecordIds` the NULL arm re-owns nothing, and the pass report appends a sentence that says so. - **Callers.** `claimSeedOwnershipOnSettle` and `runBootstrap` (through `bootstrapPlatformAdmin`'s new `seededRecordIds` option) read the reading through the contract, with `readSeededRecordIds`. The promotion pass runs in the same process as the boot's settle. A seed that settles after promotion (over budget) is claimed by the `app:seeded` re-run, as before. - **Logging.** The log meta reports an `id IN` list by its size, never inlined. - The carrier note `6158485388` is applied: `scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json` gains ADR-0138 with its D1 invariant. ### `@objectstack/organizations` (patch) `claimOrgSeedOwnership` takes the same selector. `OrganizationsPlugin` binds `seededRecordIds(object, organizationId)` for the organization the owner bind claims for. That organization's replay ran inside the `sys_organization` insert of the same call, through Middleware B. Each write carries at most `CLAIM_CHUNK_IDS` ids, which is half the ceiling. So no write can be refused for its size, and the page-read fallback from #23054 had nothing left to answer. It is removed rather than kept as unreachable code. The #23054 guarantee holds: every seeded row is claimed, however many there are, because every chunk is written. The deprecated `ensureDefaultOrganization` wrapper holds no kernel. So it has no reading and hands over no row, and its docblock says so. ### Where the landing went beyond the claim's file surface Each of these was measured as necessary: - `packages/runtime/src/app-plugin.ts`: the replayer closure and the basic-insert fallback, beside the settle hand-off. The organizations twin needs the replay's rows, which the boot settle does not carry. - `packages/plugins/organizations/src/organizations-plugin.ts` (the claim wiring) and `ensure-default-organization.ts` (its docblock only). - `scripts/engine-double-contract.pinned.json`: five rows recorded by `--write` for the two new test files' engine doubles. ## Pins | pin | where | red leg | |:--|:--|:--| | (a) a seed row is still claimed, on the first boot and on a later boot's replay | `claim-seed-ownership-seed-settle-rerun.test.ts` (two boots, real `SecurityPlugin`); `claim-seed-ownership-warm-boot.test.ts` (real `ObjectQL` over `SqlDriver`, one SQLite file, two processes) | positive control | | (b) a guest-written row and a public-form row keep no owner across two boots | the same two files, plus `claim-seed-ownership.test.ts`, the org twin's unit test and both real-engine dispatch pins | the only pin with a red leg under the old predicate | | (c) a row a hook, flow or assignment rule owned keeps its owner | the same files | preservation pin: the old predicate never matched such an owner either (the earlier dev's note in `6181198317`) | Producer side: - `seed-loader-landed-ids.test.ts`, real loader: the insert path; a replay on the skip path and on the update path; a dataset keyed on a reference reporting the matched row whose stored key is the resolved id; an unseeded row never reported; a refused insert; two records resolving to one row; a dry run. - `seed-settlement.test.ts`: the tracker. - `app-plugin.seed-landed.test.ts`: on the loader path and on the fallback, the claim inside `app:seeded` reads exactly the seed's rows; a second boot re-reports the first boot's rows and not a guest row; the replayer records under its organization. ## Measured: what the `id IN` write costs (`InMemoryDriver`) The claim's docblock records that an `id IN` predicate is quadratic on `InMemoryDriver`, where mingo hashes the list again for each row. The NULL arm now pays that shape on purpose, so it was measured. Rig: a real `ObjectQL` over `InMemoryDriver`, one object, one write, two runs each. The scratch rig was deleted after the reading. | rows | first claim, old `owner_id IS NULL` | first claim, new `owner_id IS NULL AND id IN (all)` | `claimSeedOwnership` with the reading | warm boot (all owned), old / new | |--:|--:|--:|--:|--:| | 1 000 | 53 / 17 ms | 69 / 62 ms | 61 / 66 ms | 2 / 1 ms vs 2 / 1 ms | | 5 000 | 297 / 435 ms | 2 587 / 2 650 ms | 2 431 / 3 035 ms | 2 / 3 ms vs 5 / 4 ms | So the cost is paid once, on the pass that claims the rows, and only on the in-memory driver. On a warm boot `owner_id IS NULL` fails first and the `IN` list is never consulted. SQL drivers were not measured here. ## Outside the claim's purpose (named, as triage asked) The claim's purpose is handing the boot seed to the first admin. These seed writers emit no `app:seeded`, publish no rows, and are no longer re-owned by a later boot's pass: - package-install seed apply (`runtime/src/domains/packages.ts`); - publish-time seed apply (`metadata-protocol/src/protocol.ts`, untouched per the dispatch); - the dev hot-reload seeder (`app-plugin.ts`); - the marketplace install's inline seed (`cloud-connection`). A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then. ## Open questions for the contract review 1. **The per-organization reading keeps the most recent replay only.** An organization is replayed once, when it is created. A process that keeps every replay's rows would grow with every sign-up, and the one reader, the default organization's owner bind, asks in the same call that created the organization. The cost: if another organization's replay lands between the default organization's replay and its bind, the bind reads nothing. It then hands over no row and logs it. The alternative is an unbounded per-organization map. 2. **The owner bind covers only a replay that ran in this process.** An organization that existed before this process has no reading, so a bind retried on a later boot hands over nothing. Before this change it handed over every NULL-owner row of the organization, guests' included. 3. **The fallback replayer records nothing.** `cloud-connection` registers a fallback `seed-replayer` only when no `AppPlugin` registered one, and it does not record replays. On that composition the owner bind finds no reading and hands over nothing. ## Acceptance notes - **Reading `ignore` rows.** A seed in `ignore` mode reports a row its key matched without writing it. So a pre-existing row with the same natural key counts as the seed's. That follows the loader's matching rule, not this change. - **`InMemoryDriver`'s `$in` is quadratic.** It is a property of that driver, recorded in the claim docblock with the measurement above. Carrier: none. - **Behaviour change for direct callers.** `claimSeedOwnership` and `claimOrgSeedOwnership` are exported. Without `seededRecordIds` they no longer re-own NULL-owner rows. The changeset states it. ## Tests and gates Head `70a9ae600`. Every run below went through `scripts/pm/os-verify-lock.sh`, and its `VERDICT command-exit` line is quoted. **Merge.** `origin/main` `32e03d2fd` was merged in (`090a6a0ef`, clean). Then the closure was rebuilt (`turbo run build` of `organizations…`, `plugin-security…`, `runtime…`, `cli…`, `cloud-connection…`, `platform-objects…`: 62 tasks, exit 0), and `pnpm --filter @objectstack/spec check:generated` exited 0 on the merged tree. **Typecheck: exit 0 for all 8 packages.** `spec`, `metadata-protocol`, `runtime`, `plugin-security`, `organizations`, `cli`, `cloud-connection`, `platform-objects`. `runtime` and `organizations` were run again at `70a9ae600`, exit 0. **Whole package suites:** | package | files | tests | |:--|--:|--:| | `metadata-protocol` | 237 passed | 28420 passed | | `plugin-security` | 185 passed | 3697 passed | | `organizations` (at `70a9ae600`) | 13 passed | 161 passed | | `platform-objects` | 70 passed | 1091 passed | **Targeted, at `70a9ae600`.** `runtime`: `app-plugin.seed-landed.test.ts`, `seed-settlement.test.ts` and `app-plugin.seed.test.ts` (3 files, 34 tests passed). **Declared narrowing, read from this PR's CI.** These suites were not run locally: `runtime`'s and `spec`'s whole suites, `cli` (`--project unit`), `cloud-connection`, and any `test:repo` project. The reason is lock pressure on the shared box (PM notes, 03:11Z and 03:40Z). `cli` and `cloud-connection` import the changed types only, and both typecheck clean. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 125 families, each run with its exit code captured before any pipe. Reconciliation with `--ran`: 125 accounted for, 121 run, 4 NOT MEASURED, 0 UNRUN. Two gates were red on the first pass and are green at `70a9ae600`: - `check:test-source-alias`: `organizations` now aliases `@objectstack/spec/contracts` to source. - `check:where-matcher`: the new runtime test double's matcher sits at module scope and refuses operators it does not implement. `check:engine-double-contract` recorded the new doubles (`--write`, 5 rows). NOT MEASURED, each named: - `check-plugin-teardown-shape --self-test`: exit 3. A shallow clone cannot reach the fixture commit. - `spec check:skill-examples`: exit 3. It needs a `client-react` build. - `check:dual-build-cjs-loads`: exit 3. It needs every package built. - `check:type-check-debt`: stopped. Its re-measure builds the whole tree under the shared lock, and it was withdrawn on the PM lock-pressure note. All four run in CI. **Ablation.** Not run as a separate step. Pin (b) is red by construction against the old predicate: the unit double, the real-engine pins and the warm-boot file each assert that an unreported NULL-owner row keeps no owner, and the old `owner_id IS NULL` write would re-own it. --- _Generated by [Claude Code](https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be48c5e commit b1e9280

32 files changed

Lines changed: 1717 additions & 417 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/organizations': patch
4+
---
5+
6+
fix(plugin-security, organizations): the seed-ownership claims hand over only the rows the seed wrote, and a guest-written or public-form row keeps no owner (ADR-0138 D1)
7+
8+
The claim that hands seeded records to the platform admin runs on every `app:seeded`, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object, and no stored column tells a seed row from a guest-written row or a public form's intake. Those rows keep no owner on purpose (ADR-0138 D1: "the owner stays unset"), and the claim handed them to the platform admin at the next boot.
9+
10+
- **`claimSeedOwnership`** (`@objectstack/plugin-security`) re-owns a NULL-owner row only when the seed reported landing on it: `owner_id IS NULL AND id IN (seeded ids)`, in chunks of half the per-row hook ceiling, read through the `seed-settlement` contract's `seededRecordIds`. The settle pass (`app:seeded`) and the first-admin promotion (`bootstrapPlatformAdmin`, new option `seededRecordIds`) both pass the reading. A replay reports the rows an earlier boot wrote, so a seed row is still claimed on a later boot. The `usr_system` arm is unchanged: only a seed or a legacy install writes that owner.
11+
- **`claimOrgSeedOwnership`** (`@objectstack/organizations`) takes the same selector, read for the organization the owner bind claims for: the rows that organization's seed replay landed on, which ran in the same call that created it. Each write carries at most half the per-row hook ceiling's worth of ids, so none is refused for its size and the claim no longer reads pages.
12+
- A row a hook, a flow or an assignment rule gave an owner keeps it, as before.
13+
14+
**Direct callers.** Called without `seededRecordIds`, `claimSeedOwnership` re-owns only `usr_system`-owned rows and its pass report says no seed reading was published; `claimOrgSeedOwnership` re-owns nothing and says so. The deprecated `ensureDefaultOrganization` wrapper in `@objectstack/organizations` holds no kernel, so it hands over no row; use `OrganizationsPlugin`, which binds the reading.
15+
16+
**Outside the claim's purpose** (handing the boot seed to the first admin), and so no longer re-owned by a later boot's pass: rows written by seed writers that emit no `app:seeded` — a package install's or a publish's seed apply, the dev hot-reload seeder, and the marketplace install's seed. A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(spec): the seed loader reports the rows each dataset landed on, and the seed-settlement contract hands them to readers
8+
9+
- **`SeedLoadResult.landedIds`** (`@objectstack/spec/data`, default `[]`): the ids of the stored rows a dataset's records landed on — a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. A replay therefore reports the rows an earlier load wrote, so the answer holds across boots. The loader's own key and reference resolution is behind it: a dataset keyed on a reference reports the matched row, whose stored key is the resolved id, never the dataset's value.
10+
- **`ISeedSettlementService.seededRecordIds?(object, organizationId?)`** (`@objectstack/spec/contracts`, optional): "which rows of this object did this process's seed write?". Without `organizationId` it answers for the boot-time seed sources (the inline seed `app:seeded` announces); a source publishes its rows before it settles, so a reader inside `app:seeded` already sees them. With `organizationId` it answers for that organization's per-organization replay; the host keeps the most recent replay only, so a process that creates many organizations does not hold every replay's rows. A host that does not implement it publishes no reading, and a reader treats every unowned row as one it cannot place.
11+
- **`@objectstack/metadata-protocol`**: `SeedLoaderService.load()` fills `landedIds` on every per-dataset result.
12+
- **`@objectstack/runtime`**: the `seed-settlement` service implements `seededRecordIds`. `AppPlugin` publishes the inline seed's rows before the source settles (the loader path and the basic-insert fallback alike), and its per-organization `seed-replayer` records each replay's rows under that organization. `SeedSourceHandle` gains `publishLanded(report)`.
13+
14+
Nothing is removed or renamed. The reader this exists for is the seed-ownership claim, which now hands over only the seed's rows (see the `@objectstack/plugin-security` and `@objectstack/organizations` entries).

‎content/docs/references/data/seed-loader.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Result of loading a single dataset
163163
| **referencesDeferred** | `integer` | ✅ | References deferred to second pass |
164164
| **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written |
165165
| **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset |
166+
| **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay |
166167
| **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors |
167168

168169
### Nested Shape: `SeedLoadResult.errors[number]`
@@ -298,6 +299,7 @@ Result of loading a single dataset
298299
| **referencesDeferred** | `integer` | ✅ | References deferred to second pass |
299300
| **referencesDropped** | `integer` | optional (default: `0`) | Reference fields dropped from records that were still written |
300301
| **summariesStale** | `integer` | optional (default: `0`) | Roll-up summary values left stale by writes for this dataset |
302+
| **landedIds** | `string[]` | optional (default: `[]`) | Ids of the stored rows this dataset landed on: inserted, updated, or matched on replay |
301303
| **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | optional (default: `[]`) | Reference resolution errors |
302304

303305
### Nested Shape: `SeedLoaderResult.errors[number]`
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { SeedLoaderService } from './seed-loader.js';
5+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
import { SeedLoadResultSchema } from '@objectstack/spec/data';
7+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
8+
9+
/**
10+
* #23354 — `SeedLoadResult.landedIds`: the rows each dataset landed on.
11+
*
12+
* The seed-ownership claim (ADR-0138 D1) may hand to the platform admin only
13+
* the unowned rows the SEED wrote: a guest-written row and a public form's
14+
* intake keep no owner, and no stored column tells them from a seed row. The
15+
* loader is the one place that knows which rows its records resolved to — it
16+
* matches natural keys, resolves references to the target's id first, and
17+
* re-identifies rows per organization — so it publishes the answer instead of
18+
* a reader re-deriving it from the dataset.
19+
*
20+
* What the claim depends on, pinned here:
21+
*
22+
* - the insert path reports the ids it wrote;
23+
* - a REPLAY reports the rows an earlier load wrote, on the skip path and on
24+
* the update path alike — that is what keeps the claim correct on every
25+
* later boot;
26+
* - a dataset keyed on a REFERENCE reports the matched row: the stored key is
27+
* the resolved id, never the dataset's own value, which is exactly what a
28+
* reader copying the dataset would get wrong;
29+
* - a row the load did not land (a refused insert) and a dry run report none.
30+
*
31+
* The engine is a faithful in-memory double (`where` filters, ids minted on
32+
* insert), the same shape `seed-loader-replay.test.ts` drives; the loader is
33+
* the real one.
34+
*/
35+
36+
function createLogger() {
37+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
38+
}
39+
40+
function createFaithfulEngine(opts: { refuseInsert?: (object: string, row: any) => boolean } = {}) {
41+
const store: Record<string, any[]> = {};
42+
let idCounter = 0;
43+
const mint = (object: string, d: any) => {
44+
if (opts.refuseInsert?.(object, d)) {
45+
throw Object.assign(new Error('Validation failed: name is reserved'), { code: 'VALIDATION_FAILED', status: 400 });
46+
}
47+
return { id: `gen-${++idCounter}`, ...d };
48+
};
49+
50+
const engine = {
51+
find: vi.fn(async (objectName: string, query?: any) => {
52+
let records = store[objectName] || [];
53+
if (query?.where) {
54+
records = records.filter((r) =>
55+
Object.entries(query.where).every(([k, v]) => {
56+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
57+
return r[k] === v;
58+
}),
59+
);
60+
}
61+
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
62+
return records;
63+
}),
64+
findOne: vi.fn(async (objectName: string, query?: any) => {
65+
assertEngineFindOnePredicate(objectName, query);
66+
const rows = await (engine.find as any)(objectName, { ...query, limit: 1 });
67+
return rows[0] ?? null;
68+
}),
69+
insert: vi.fn(async (objectName: string, data: any) => {
70+
if (!store[objectName]) store[objectName] = [];
71+
if (Array.isArray(data)) {
72+
const records = data.map((d) => mint(objectName, d));
73+
store[objectName].push(...records);
74+
return records;
75+
}
76+
const record = mint(objectName, data);
77+
store[objectName].push(record);
78+
return record;
79+
}),
80+
update: vi.fn(async (objectName: string, data: any) => {
81+
assertEngineUpdateDispatch(data, undefined);
82+
const records = store[objectName] || [];
83+
const idx = records.findIndex((r) => r.id === data.id);
84+
if (idx >= 0) {
85+
records[idx] = { ...records[idx], ...data };
86+
return records[idx];
87+
}
88+
return data;
89+
}),
90+
delete: vi.fn(async (_objectName: string, options?: any) => {
91+
assertEngineDeleteDispatch(options);
92+
return { deleted: 1 };
93+
}),
94+
count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
95+
aggregate: vi.fn(async () => []),
96+
} as unknown as IDataEngine;
97+
98+
return { engine, store };
99+
}
100+
101+
function createMetadata(): IMetadataService {
102+
const objects: Record<string, any> = {
103+
lid_team: { name: 'lid_team', fields: { name: { type: 'text' } } },
104+
lid_case: { name: 'lid_case', fields: { name: { type: 'text' }, status: { type: 'text' } } },
105+
// A junction keyed on its REFERENCE: the stored `team` is the resolved
106+
// team id, never the dataset's natural key.
107+
lid_membership: {
108+
name: 'lid_membership',
109+
fields: {
110+
team: { type: 'lookup', reference: 'lid_team', required: true },
111+
role: { type: 'text' },
112+
},
113+
},
114+
};
115+
return {
116+
getObject: vi.fn(async (name: string) => objects[name]),
117+
listObjects: vi.fn(async () => Object.values(objects)),
118+
register: vi.fn(async () => {}),
119+
get: vi.fn(async (_t: string, name: string) => objects[name]),
120+
list: vi.fn(async () => []),
121+
unregister: vi.fn(async () => {}),
122+
exists: vi.fn(async () => false),
123+
listNames: vi.fn(async () => []),
124+
} as unknown as IMetadataService;
125+
}
126+
127+
// The AppPlugin inline-seed config: upsert, multi-pass.
128+
const CONFIG = {
129+
dryRun: false,
130+
haltOnError: false,
131+
multiPass: true,
132+
defaultMode: 'upsert',
133+
batchSize: 1000,
134+
transaction: false,
135+
} as any;
136+
137+
const SEEDS = [
138+
{
139+
object: 'lid_team',
140+
externalId: 'name',
141+
mode: 'upsert',
142+
records: [{ name: 'Experience' }, { name: 'Platform' }],
143+
},
144+
{
145+
object: 'lid_case',
146+
externalId: 'name',
147+
mode: 'upsert',
148+
records: [
149+
{ name: 'CASE-1', status: 'open' },
150+
{ name: 'CASE-2', status: 'open' },
151+
],
152+
},
153+
{
154+
object: 'lid_membership',
155+
externalId: 'team',
156+
mode: 'upsert',
157+
records: [{ team: 'Experience', role: 'lead' }],
158+
},
159+
] as any[];
160+
161+
function landed(result: { results: Array<{ object: string; landedIds: string[] }> }, object: string): string[] {
162+
return result.results.filter((r) => r.object === object).flatMap((r) => r.landedIds);
163+
}
164+
165+
const idsOf = (rows: any[] | undefined) => (rows ?? []).map((r) => r.id);
166+
167+
describe('SeedLoadResult.landedIds — the rows a seed landed on (#23354)', () => {
168+
it('the insert path reports the ids it wrote, per dataset, in record order', async () => {
169+
const { engine, store } = createFaithfulEngine();
170+
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG });
171+
172+
expect(result.success).toBe(true);
173+
expect(landed(result, 'lid_team')).toEqual(idsOf(store.lid_team));
174+
expect(landed(result, 'lid_case')).toEqual(idsOf(store.lid_case));
175+
expect(landed(result, 'lid_membership')).toEqual(idsOf(store.lid_membership));
176+
expect(landed(result, 'lid_case')).toHaveLength(2);
177+
// The published shape is the spec's: it parses, and the field survives.
178+
for (const r of result.results) expect(SeedLoadResultSchema.parse(r).landedIds).toEqual(r.landedIds);
179+
});
180+
181+
it('a replay of an unchanged seed reports the rows the FIRST load wrote (skip path)', async () => {
182+
const { engine, store } = createFaithfulEngine();
183+
const metadata = createMetadata();
184+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
185+
const firstIds = { team: idsOf(store.lid_team), cases: idsOf(store.lid_case), membership: idsOf(store.lid_membership) };
186+
187+
const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
188+
189+
// Positive control: this really was the skip path, nothing written.
190+
expect(replay.summary.totalInserted).toBe(0);
191+
expect(replay.summary.totalUpdated).toBe(0);
192+
expect(replay.summary.totalSkipped).toBe(5);
193+
expect(landed(replay, 'lid_team')).toEqual(firstIds.team);
194+
expect(landed(replay, 'lid_case')).toEqual(firstIds.cases);
195+
expect(landed(replay, 'lid_membership')).toEqual(firstIds.membership);
196+
});
197+
198+
it('a replay that updates the rows reports them too (update path)', async () => {
199+
const { engine, store } = createFaithfulEngine();
200+
const metadata = createMetadata();
201+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
202+
for (const row of store.lid_case) row.status = 'edited at runtime';
203+
204+
const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
205+
206+
expect(replay.results.find((r) => r.object === 'lid_case')!.updated).toBe(2);
207+
expect(landed(replay, 'lid_case')).toEqual(idsOf(store.lid_case));
208+
});
209+
210+
it('a dataset keyed on a reference reports the matched row, whose stored key is the resolved id', async () => {
211+
const { engine, store } = createFaithfulEngine();
212+
const metadata = createMetadata();
213+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
214+
const experienceId = store.lid_team.find((r) => r.name === 'Experience')!.id;
215+
const membership = store.lid_membership[0];
216+
// The trap a reader copying the dataset falls into: the stored key is NOT
217+
// the dataset's value.
218+
expect(membership.team).toBe(experienceId);
219+
expect(membership.team).not.toBe('Experience');
220+
221+
const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
222+
223+
expect(replay.results.find((r) => r.object === 'lid_membership')!.skipped).toBe(1);
224+
expect(landed(replay, 'lid_membership')).toEqual([membership.id]);
225+
});
226+
227+
it('a row that is not in the seed is never reported, even when it shares the object', async () => {
228+
const { engine, store } = createFaithfulEngine();
229+
const metadata = createMetadata();
230+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
231+
// Written by someone else between boots — a guest, a form door, a user.
232+
store.lid_case.push({ id: 'guest-row', name: 'Guest enquiry', status: 'new' });
233+
234+
const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
235+
236+
expect(landed(replay, 'lid_case')).not.toContain('guest-row');
237+
expect(landed(replay, 'lid_case')).toHaveLength(2);
238+
});
239+
240+
it('a refused insert lands no row, so it reports no id', async () => {
241+
const { engine, store } = createFaithfulEngine({ refuseInsert: (o, row) => o === 'lid_case' && row.name === 'CASE-2' });
242+
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: SEEDS, config: CONFIG });
243+
244+
expect(result.results.find((r) => r.object === 'lid_case')!.errored).toBe(1);
245+
expect(store.lid_case.map((r) => r.name)).toEqual(['CASE-1']);
246+
expect(landed(result, 'lid_case')).toEqual(idsOf(store.lid_case));
247+
});
248+
249+
it('two records that resolve to one row report it once', async () => {
250+
const { engine, store } = createFaithfulEngine();
251+
const metadata = createMetadata();
252+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
253+
const doubled = [{
254+
object: 'lid_case',
255+
externalId: 'name',
256+
mode: 'upsert',
257+
records: [{ name: 'CASE-1', status: 'open' }, { name: 'CASE-1', status: 'open' }],
258+
}] as any[];
259+
260+
const replay = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: doubled, config: CONFIG });
261+
262+
const case1 = store.lid_case.find((r) => r.name === 'CASE-1')!.id;
263+
expect(landed(replay, 'lid_case')).toEqual([case1]);
264+
});
265+
266+
it('a dry run writes nothing and reports nothing', async () => {
267+
const { engine, store } = createFaithfulEngine();
268+
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
269+
seeds: SEEDS,
270+
config: { ...CONFIG, dryRun: true },
271+
});
272+
273+
// Positive control: the dry run did count the would-be inserts.
274+
expect(result.summary.totalInserted).toBe(5);
275+
expect(store.lid_case).toBeUndefined();
276+
for (const r of result.results) expect(r.landedIds).toEqual([]);
277+
});
278+
});

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,19 @@ export class SeedLoaderService implements ISeedLoaderService {
18351835
if (internalId) entry.internalId = internalId;
18361836
}
18371837

1838+
// [#23354] The same map, published: the rows this dataset landed on. Every
1839+
// write site records through `noteLanded` — an insert, an update, and a key
1840+
// matched on a replay and skipped — so a replay reports the rows an earlier
1841+
// load wrote, and the seed-ownership claim (ADR-0138 D1) can hand over
1842+
// exactly the seed's rows without a second copy of the key and reference
1843+
// rules above. Record order, each id once: two records can resolve to one
1844+
// row. A dry run never calls `noteLanded`, so it reports none.
1845+
const landedIds = [...new Set(
1846+
[...internalIdByRecordIndex.entries()]
1847+
.sort(([a], [b]) => a - b)
1848+
.map(([, id]) => id),
1849+
)];
1850+
18381851
return {
18391852
object: objectName,
18401853
mode,
@@ -1847,6 +1860,7 @@ export class SeedLoaderService implements ISeedLoaderService {
18471860
referencesDeferred,
18481861
referencesDropped,
18491862
summariesStale: this.summariesStale - summariesStaleAtStart,
1863+
landedIds,
18501864
errors,
18511865
};
18521866
}

0 commit comments

Comments
 (0)