Repository navigation
Commit 4123865
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
- .changeset
- packages
- cli/src/utils
- metadata-protocol/src
- platform-objects/src
- system
- plugins/plugin-audit/src
- runtime/src
- spec/src/system/constants
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
820 | 820 | | |
821 | 821 | | |
822 | 822 | | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
823 | 829 | | |
824 | 830 | | |
825 | 831 | | |
| |||
0 commit comments