Skip to content

Commit 4123865

Browse files
fix(metadata-protocol,runtime): a seed row a person deleted is no longer inserted again by the next seed replay (per-organization record of landed seed keys) (#31083)
Fixes #31021 Clause-②: yes (widening) Metadata-protocol: semantic Skills-docs: follow-up #31090 Claim: 6701308503 (File surface amended in review of this PR) · Branch: `claude/issue-31021-seed-landed-keys` · File surface: the seed loader and its tests, AppPlugin's seed loads, the new platform object `sys_seed_landed_key` with its registration and the regenerated `scripts/platform-object-tenancy-census.json`, one changeset, and the three lines the object forces outside the server package trees: the roster line `'sys_seed_landed_key'` in `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (`packages/spec`), its `ORGANIZATION_OWNERSHIP_INVENTORY` row (`packages/cli`), and its entry in plugin-audit's `SKIP_OBJECTS` · Spec review: the roster line owes a `CONTRACT_REVIEW_TIER` review, requested from `domain:spec#1` ## 维护者速读 - 改了什么:平台为每个组织记下哪些种子行已经落过地,记录存在新的系统对象 `sys_seed_landed_key` 里。之后启动或升级重放种子时,被人删掉的种子行不再按新 id 插回来,升级新加的种子行照常插入。 - 为什么改:cloud 实测,用户删掉的示例数据在下次装包或升级时会回来(cloud#3657)。自托管的 `objectstack serve` 每次启动重放种子,问题一样。平台的问题在平台处理。 - 风险与回滚:只有 AppPlugin 的三条种子加载会读写这份记录(启动种子、按组织重放、dev 热加载),包发布、市场安装、重新灌样例数据等其他入口不变。记录写失败只报一次 error,不影响种子行本身。新对象带来的三行也在本 PR:spec 平台对象名册加一行、CLI 组织归属清单加一行、审计跳过表加一行(记录行不进审计日志)。回滚就是 revert 本 PR,多出的表无害。 - 先看:`packages/metadata-protocol/src/seed-loader.ts` 的 `isWithdrawnRow`(不再插回被删的行)、`resolveReferenceItem` 的 `withdrawn` 分支(指向被删行的引用不报错)、`recordLandedKeys`(同一轮写记录);`packages/runtime/src/app-plugin.ts` 的 `seedLoaderOptionsFor` 和 replayer 的 `reseed` 选项;`packages/spec/src/system/constants/platform-object-names.ts` 名册那一行。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/seed-loader-landed-ids.test.ts -t "31021"`,预期 13 条全绿:删掉的行不回来、改过的行不动、升级新行照插、reseed 能恢复、记录按组织分开。 - 需要你做的:无,席位落地(spec 名册那一行由 `domain:spec#1` 做契约复审)。 ## What changes **`@objectstack/metadata-protocol` — `SeedLoaderService`** (`src/seed-loader.ts`) - New constructor option `landedKeys: 'honour' | 'reseed'` on `SeedLoaderServiceOptions`. A loader built without it reads and writes nothing new, so every other caller behaves as before. - When it is set, the loader opens a per-load record for the organization the load seeds. That organization is `config.organizationId`, or the install's sole organization (`fallbackOrgId`, the `single` posture's Default Organization on a `serve` boot). If the load seeds no organization, it keeps no record and says so once at `info`. If `sys_seed_landed_key` is not registered, it keeps no record and says so once at `warn`. - For an `upsert` or `ignore` dataset, the loader records the key of every row the dataset lands, in the same pass that writes it. A landed row is one that was inserted, updated, or matched and left alone. The key is the dataset's `externalId` field names with the values the row was matched by, JSON-encoded, for example `[["name","Acme"]]`. JSON keeps the stored key free of the in-memory NUL joiner. An `id` key is recorded under the AUTHORED id, not the per-organization id a replay may assign (#21665). - **The rule:** a keyed row the load cannot find is inserted only when its key never landed in that organization. A recorded key whose row is gone is not inserted again. Such a row is counted as `skipped`, and one `info` line per load names how many and on which objects. - **References to a removed row:** after every database probe misses, `resolveReferenceItem` answers `withdrawn` when the record names the target key as landed. A single link is left unwritten: a matched row keeps what it holds, and a created row lands without the link. A multi-value link keeps the elements that still resolve. A row whose own `externalId` holds such a reference is withdrawn with it. Nothing is deferred and nothing is reported unresolved, so a replay after a person's delete still reads `success: true`. Pass 2 settles a deferred element the same way. When the record is in use, the key's own references now resolve before any other reference, so a withdrawn row resolves and defers nothing. - A record read that fails for any reason other than a missing table propagates, the same as `loadExistingRecords`. A record write that fails is reported once at `error` and fails neither the rows nor the load. The next load records every row that is still present. **`@objectstack/runtime` — `AppPlugin`** (`src/app-plugin.ts`) - All three seed loads AppPlugin runs honour the record: the boot (inline) seed that `objectstack serve` and `os start` run, the per-organization `seed-replayer` that hosts and `sys_organization` insert call, and the dev hot-reload seeder. - **The door for a deliberate re-seed:** `seed-replayer(organizationId, { reseed: true })`. It inserts every missing row, including the ones a person removed, and records them again. A new database starts with an empty record, and deleting an organization cascades its record. **`@objectstack/platform-objects` — `sys_seed_landed_key`** (`src/system/sys-seed-landed-key.object.ts`) - `SysSeedLandedKey` is organization-owned. Its organization column cascades. It is engine-owned, gated by `manage_platform_settings`, and has no data API and no lifecycle block, so it is durable. It has one non-unique index on `(organization_id, object_name)`. `PlatformObjectsPlugin` registers it and the package exports it. `scripts/platform-object-tenancy-census.json` is regenerated: 97 registered, 50 in reach. **The three lines the object forces** - `@objectstack/spec`: `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lists `'sys_seed_landed_key'` in sorted position. `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName` follow, so a stack that references the object now resolves it instead of drawing the unknown-platform-name advisory. Nothing that was accepted is refused. This follows the `sys_lifecycle_state` precedent (#30751). - `@objectstack/cli`: `ORGANIZATION_OWNERSHIP_INVENTORY` has a row for the object: `census: platform-census-main`, fate 3 `attribution`, `anchors: []`, cited `(in reach, added since the first census)` with `D10_SINGLE`. This is the inventory's disposition for an organization-owned, engine-written table, the same as `sys_recycle_bin` and `sys_connector_connection`. The loader writes every row with the organization its load seeds and writes none without one, so a NULL row has no producer, and a key names its seed row by natural key, so no parent row anchors it. The `column-drop` fate of `sys_lifecycle_state` applies only to tenant-less tables, and this one is not tenant-less. - `@objectstack/plugin-audit`: `SKIP_OBJECTS` lists `'sys_seed_landed_key'` beside `sys_lifecycle_state`. Recording which seed rows landed writes no `sys_audit_log` or `sys_activity` row of its own; the seeded rows themselves stay audited. Why a new object rather than existing state: no existing platform object fits. `sys_setting` is the settings K/V store, and its own header refuses internal state. `sys_migration` and `sys_lifecycle_state` are deployment-level and tenant-less. `sys_metadata_activation` is an activation boolean per artifact. `sys_recycle_bin` is reaped after its retention window, so a removed row would come back after it. The record has to sit in the same database as the rows it describes, scoped by organization. That is why it is not a host seam like the package-state store (#31008). ## Tests <details><summary>New pins and their runs (loader, AppPlugin and object at d1abef0; the three forced lines at 6a1892e)</summary> - `packages/metadata-protocol/src/seed-loader-landed-ids.test.ts` — a new top-level describe with 13 cases in the existing landed-ids harness. They cover: the key recorded per organization and recorded once; a deleted row not re-inserted by a reboot or by a seed-union change, while the upgrade's new row lands; the same under `upsert`; an edited row and a renamed key untouched; a reference to a removed row left as it is (single and multi-value, no error, no deferral); a row keyed on a removed row withdrawn with it; the `reseed` door; isolation between organizations, with a new organization getting the whole seed; an `id`-keyed row recorded under its authored id; no organization or no registered object keeping no record, saying so once; a loader not asked neither reading nor writing; a failed record write reported once at `error` with the load succeeding; a failed record read stopping the load. - `packages/runtime/src/app-plugin.seed-landed.test.ts` — a new top-level describe with 2 cases. In the first, `serve`'s inline seed with the sole Default Organization keeps a deleted row deleted across a reboot and an upgrade, and the upgrade's new row lands. In the second, the per-organization replayer keeps the record per organization, and `{ reseed: true }` brings the row back. - `packages/platform-objects/src/plugin.test.ts` — the registered list now includes the object, and its shape is pinned. - The roster line is pinned both ways by the existing `packages/spec/src/system/constants/platform-object-names.test.ts`, and the inventory row by the existing `packages/cli/src/utils/organization-ownership-inventory.test.ts`, which enumerates that roster. ``` pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 → Test Files 321 passed | 4 skipped (325) · Tests 22525 passed | 26 skipped pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/app-plugin.seed-landed.test.ts → Tests 6 passed (6) pnpm --filter @objectstack/runtime test (merged tree f72ceee) → Test Files 553 passed (553) · Tests 8878 passed | 19 skipped pnpm --filter @objectstack/platform-objects test → Test Files 117 passed (117) · Tests 1638 passed typecheck metadata-protocol / runtime / platform-objects → Done ×3 (head 6a1892e) pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/constants/platform-object-names.test.ts → exit 0 · Tests 14 passed (14) pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/utils/organization-ownership-inventory.test.ts → exit 0 · Tests 26 passed (26) pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 src/audit-writers.test.ts src/audit-log-read-visibility.test.ts src/activity-read-visibility.test.ts → exit 0 · Test Files 3 passed · Tests 203 passed typecheck spec / cli / plugin-audit (NODE_OPTIONS=--max-old-space-size=4096) → exit 0 ×3 pnpm check:platform-object-tenancy-census → exit 0 · 97 objects, 50 in reach, 47 outside ``` </details> <details><summary>Ablations — each negative pin, old behaviour put back, red, restored (scripts/ablation-replace.mjs, blob == HEAD after each)</summary> | # | Mutation (old behaviour) | Result | |---|---|---| | A1 | `isWithdrawnRow` answers `false` (a removed row is inserted again) | 8 failed / 19 passed (27). Example: `expected [ 'Acme', 'Globex' ] to deeply equal [ 'Acme' ]` | | A2 | the record never answers a reference (`withdrawn` branch deleted) | 4 failed / 23 passed. `expected false to be true` on `success`: the pass-2 unresolved errors return | | A3 | `recordLandedKeys` never called | 10 failed / 17 passed | | A4 | AppPlugin passes no `landedKeys` | 2 failed / 4 passed (6). `expected [ 'CASE-1', 'CASE-2' ] to deeply equal [ 'CASE-2' ]` | | A5 | the replayer ignores `{ reseed: true }` | 1 failed / 5 passed. `expected { inserted: +0, … } to match object { inserted: 1 }` | | A6 | the record is on for a loader that did not ask | 1 failed / 26 passed | The loader ablations ran against `src` (the metadata-protocol tests import `./seed-loader.js`). The AppPlugin ablations ran against runtime `src/app-plugin.ts`. No ablation went through a `dist/` build. All six ran at 7a89f01. The lines they mutate are byte-identical at d1abef0. </details> ## Acceptance notes **The `SKIP_OBJECTS` entry has no case of its own.** The amended File surface names the line, not `audit-writers.test.ts`, so no `sys_seed_landed_key` case was added beside the `sys_lifecycle_state` one. `audit-writers.test.ts` and the two read-visibility suites stay green with the entry. **Boundaries, by design:** - Only AppPlugin's loads ask for the record. These doors keep today's behaviour: the package-publish apply (`protocol-publish-batch.ts`), runtime `domains/packages.ts`, and cloud-connection install-local's install, heal, reseed and purge. The install-local reseed is the existing "restore sample data" door, and it never reads the record, so it still restores. A row it restores is present, so later replays leave it alone. - A row deleted BEFORE this lands has no record. The first replay after the upgrade inserts it once more and records it, the same as today. From then on, a delete is remembered. - A composition with no organization object keeps no record and says so at `info`. - A seeded object with tenancy disabled is recorded under the load's organization. An organization that never landed the global row, such as a new organization's first replay, inserts it again. - `Metadata-protocol: semantic`: the meaning of an authored `mode: 'ignore'` or `mode: 'upsert'` on replay changes. A removed row whose key landed is no longer "a row the dataset is missing". No key is added. The roster line would make the kind `added` as well, and `semantic` outranks `added`. `Clause-②: yes (widening)`: `SysSeedLandedKey` is a new export of `@objectstack/platform-objects`, `SeedLoaderServiceOptions` gains `landedKeys`, and the platform object roster lists one more real object. There is no new schema option and no new error code. The changeset is `minor` for every package this diff moves (metadata-protocol, runtime, platform-objects, spec, cli, plugin-audit), because a kind other than `none` takes `minor` (AGENTS.md, Post-Task Checklist step 3; #30751's changeset did the same). - The docs owe a follow-up. `content/docs/data-modeling/seed-data.mdx` (Import Modes) still says `ignore` inserts "a record whose row is missing". It should say that a removed seed row is not inserted again, and name the reseed door. - Hosted: the card's cloud rig reaches the loader through AppPlugin's `seed-replayer`. This session has no read access to objectstack-ai/cloud, so the `ArtifactKernelFactory` call site is NOT MEASURED here. `packages/cli` has no seed call site of its own: `serve` seeds through AppPlugin, and `serve.ts` only reads the settlement. ## Verification <details><summary>Gates (derived by dispatch-gates --commands at 6a1892e, exit codes written to disk before any reading)</summary> All 98 derived families were run locally at 6a1892e. `dispatch-gates --ran` reports 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN, and every family exits 0. - On the first pass of this head, `check:dual-build-cjs-loads` and `check:i18n-coverage` exited 3 (PREREQUISITE NOT MET: the worktree held no full build). After `turbo run build` succeeded (119/119), both exit 0. - At d1abef0, `check:query-options-erasure` and `check:test-source-alias` were real findings, and both were fixed at that head: the ledger read's query bag was typed as `EngineQueryOptions`, and a module-top `import '@objectstack/metadata'` was added to the AppPlugin seed test. - `pnpm lint` → exit 0 at 6a1892e. </details> <!-- signed: domain:server#1 · sunhaolin · session_017xRBcTJTE7kSbcMoJQ4Txf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 719a502 commit 4123865

13 files changed

Lines changed: 1062 additions & 14 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/platform-objects': minor
5+
'@objectstack/spec': minor
6+
'@objectstack/cli': minor
7+
'@objectstack/plugin-audit': minor
8+
---
9+
10+
fix(metadata-protocol,runtime): a seed row a person deleted is no longer inserted again under a new id by the next seed replay
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: semantic
14+
15+
A seed replays long after its first load: on every boot of a single-tenant runtime (`objectstack serve`, `os start`) and per organization whenever a host replays the seed union, for example on a package upgrade. The create-only default (`mode` omitted, which is `ignore`) inserted every declared row it did not find, and `upsert` did the same. A row a person deleted therefore came back under a new id at the next replay.
16+
17+
The seed loads AppPlugin runs now keep a per-organization record of which seed rows landed: the boot seed, the per-organization replay (the `seed-replayer` service) and the dev hot-reload seeder. The record is the new platform object `sys_seed_landed_key` (`SysSeedLandedKey`, registered by `PlatformObjectsPlugin`). It holds one row per landed key: the organization, the object, and the dataset's `externalId` fields with the values the row was matched by. The loader records every keyed row an `upsert` or `ignore` dataset lands, whether it inserted the row, updated it or matched it and left it alone. A row an earlier release seeded therefore counts as landed from the first load after the upgrade. A missing row is inserted only when its key never landed in that organization:
18+
19+
- A deleted seed row stays deleted across reboots and upgrades. A row an upgrade adds to the seed still lands.
20+
- An edited row is left as the person left it. Under `ignore` this includes a renamed key: the old name is not inserted again.
21+
- A reference to a removed row is left as it is, and a row whose own key names a removed row is withdrawn with it. Nothing is reported unresolved for either, so a replay after a person's delete still loads clean.
22+
- One `info` line per load says how many seed rows were not inserted again, and where.
23+
24+
The deliberate re-seed is the `seed-replayer` service called with `{ reseed: true }`. It inserts every missing row, including the ones a person removed, and records them again. A new database starts with an empty record. Deleting an organization deletes its record.
25+
26+
`SeedLoaderService` takes the record through a new constructor option, `landedKeys: 'honour' | 'reseed'`. A caller that does not pass it neither reads nor writes the record, so the package publish, marketplace install, reseed and purge doors behave as before. A load keeps no record when it seeds no organization, and says so at `info`. It also keeps none when the composition does not register `sys_seed_landed_key`, and says so at `warn`. A record write that fails is reported once at `error`. It fails neither the rows nor the load, and the next load records every row that is still present.
27+
28+
`PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` in `@objectstack/spec` lists the new object, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`, so a stack that references `sys_seed_landed_key` resolves it instead of drawing the unknown-platform-name advisory. `@objectstack/plugin-audit` leaves `sys_seed_landed_key` out of `sys_audit_log` and `sys_activity`: recording which seed rows landed writes no ledger row of its own, and the seeded rows stay audited. `os migrate organization-ownership` inventories the new table as organization-owned rows (fate 3, attribution), each written with the organization its load seeds.

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
820820
// organization). The record an entry names is gone, so no parent row
821821
// anchors an entry.
822822
{ object: 'sys_recycle_bin', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
823+
// The record of landed seed rows (`@objectstack/platform-objects`, #31021):
824+
// the seed loader writes each row with the organization the load seeds and
825+
// writes none when the load seeds no organization, so a NULL row has no
826+
// producer. A key names a seed row by its natural key, not by id, so no
827+
// parent row anchors it.
828+
{ object: 'sys_seed_landed_key', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
823829
// A reader's own named list views (`@objectstack/platform-objects`, ruling
824830
// 6570568131 on #28971): user data on the preference template, each row in
825831
// its owner's organization; no parent row anchors a view.

0 commit comments

Comments
 (0)