Skip to content

Commit 2178d80

Browse files
docs(skills): teach the seed modes the loader runs (ignore default, replace deletes nothing) and that a deleted seed row stays deleted (#31097)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xTkTkeVm9Y984jksBytJ867)_ Fixes #31090 Item 3 of the card (the seed `mode` text in `packages/spec/src/data/seed.zod.ts`, comment 6702892720) is routed to the spec lane as #31099 (with the stale `replace` string in `packages/cli/src/commands/diff.ts:81`). This PR does not touch `packages/spec`; the wording owed is listed below. Clause-②: no Metadata-protocol: none `dispatch-gates --tier` reads a MANDATORY model tier for `skills/**` (clause ① of the model-tiering ruling); it is waived by the standing order of the maintainer in this project (2026-11-03), and this card ran on the project's default model. The dispatching seat runs the contract review. ## 维护者速读 - 改了什么:`objectstack-data` 技能的 `references/seeds.md` 改正种子模式:默认是 `ignore`(只建不改),`replace` 不删任何行,`insert` 每次重放都会重复插入;并补一句「AppPlugin 的启动和按组织重放不会把人删掉的种子行插回(发布、装包不记账,会插回),`seed-replayer` 带 `{ reseed: true }` 才恢复」。`content/docs/data-modeling/seed-data.mdx` 新增一小节讲同一规则,并列出不记账的加载入口。 - 为什么改:技能表格仍写 `upsert`(默认)和 `replace` =「先删光再插」,自 #30081 起已不成立;PR #31083 落地的「已落地键」规则文档里没有,客户想找回示例数据没有文档可查。 - 风险与回滚:只改文字和一条评测的期望描述;`seeds.md` token 1566 → 1560(上限不动);发 `@objectstack/skills` patch;回滚即 revert。 - 先看:`skills/objectstack-data/references/seeds.md` 的「Import modes」表及其下一段,`seed-data.mdx` 新小节「A Deleted Seed Row Stays Deleted」,对照下文「Code sites」。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期退出 0,`skills/objectstack-data/references/seeds.md is 1560 tokens (ceiling 1566; headroom 6)`。 - 需要你做的:`skills/**` 属 Tier H(人合),需要 `os-zhuang` 或 `hotlong` 的一次 approval,之后席位落地。 ## What changes **`skills/objectstack-data/references/seeds.md`** (whole-file sweep): ```diff -| `externalId` | `'name'` | Stable business key used for upsert / update lookup | -| `mode` | `'upsert'` | Import strategy (see below) | +| `externalId` | `'name'` | Stable business key every keyed mode matches on | +| `mode` | `'ignore'` | Import strategy (see below) | @@ Import modes -| `upsert` (default) | Update by `externalId`, insert if missing. Idempotent. | Reference data, bootstrap rows | -| `insert` | Insert all; fail on duplicate `externalId`. | Append-only / audit tables | +| `ignore` (default) | Create-only: insert a missing row, leave a matched row as is. | Data the customer owns | +| `upsert` | Insert if missing; every replay writes declared columns back. | Reference data kept current | +| `insert` | Insert every record, no match: duplicates on each replay. | Rows loaded once | | `update` | Update only existing rows; never create. | Patching existing config | -| `ignore` | Insert; silently skip duplicates. | Additive bootstrap | -| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data | +| `replace` ⚠️ | Insert with no match; deletes nothing, the caller clears the table. | Cache / lookup tables only | + +A seed replays on each boot, install and re-publish. AppPlugin's boot and organization +replays (not a publish or package install) do not re-insert a keyed row a +person deleted; `seed-replayer` with `{ reseed: true }` restores it. @@ Seed best practices -| Default to `upsert` | Idempotent re-runs | +| Leave `mode` unset; `upsert` only for reference data | Replays keep customer edits | -| Use `replace` only on cache/lookup tables, with comments | Data-loss footgun | +| `replace` only on cache tables the caller clears | It never deletes: duplicates otherwise | ``` Also in that file: - Quick start: the demo `categorySeed` (≈L36) drops `mode: 'upsert'`, so demo data takes the create-only default (as `seed-data.mdx` teaches). The reference `statusSeed` (≈L26) keeps `mode: 'upsert'`: reference data is the case `upsert` is for. - `applyOn` section: its example `defineSeed(Invoice, …)` gains `mode: 'upsert'`. Without it the example ran under `ignore`, where `applyOn: 'create'` changes nothing, so it silently relied on the old default. **`skills/objectstack-data/evals/hooks-security-seeds-datasources.json`**, case 4. The demo `Opportunity` seed had no `mode`, yet the case required `applyOn: 'create'` to keep a replay off `stage`, which only matters under the old `upsert` default. The prompt now asks for demo rows refreshed from the seed on every replay except `stage`, so the right answer is `mode: 'upsert'` plus `columns: { stage: { applyOn: 'create' } }`, and `expected_output` says so. The assertions are unchanged and every one of them is now live (review S3: the dataset declares `upsert`, rather than dropping the `must_contain`). **`content/docs/data-modeling/seed-data.mdx`**: a new subsection, "A Deleted Seed Row Stays Deleted", after the `insert` subsection: the landed-keys rule; the loads that keep and honour it (AppPlugin's single-tenant boot seed, per-organization replay and dev hot reload, for a known organization); the loads that keep no record, where a deleted row comes back (a seed publish, a package install or upgrade including the marketplace install, heal and sample-data reseed, a load with no organization, a composition without `sys_seed_landed_key`, the marketplace's fallback `seed-replayer`); the once-more case for a row deleted before the upgrade; and the `seed-replayer` `{ reseed: true }` door. The `ignore` and `upsert` rows of the modes table link to it, scoped to "a boot or organization replay". The rest of the page (`:74-80`) already matched the loader. Plus `.changeset/skills-data-seed-modes-landed-keys.md`: `'@objectstack/skills': patch`. ## Why An AI that follows the published skill writes seeds expecting `upsert` replays by default and a `replace` that clears the table. Neither is what the loader does since #30081. After #31021 (PR #31083), a seed row a person deleted stays deleted across replays, and nothing documented that or how to restore it. ## Code sites (each taught fact, measured on `main` b631573) | Taught fact | Code site | |---|---| | The default `mode` is `ignore`, and the loader's `defaultMode` is that same default | `packages/spec/src/data/seed.zod.ts:193` `mode: SeedMode.default('ignore')`; `packages/spec/src/data/seed-loader.zod.ts:253`; `packages/metadata-protocol/src/seed-loader.ts:1303` `dataset.mode \|\| config.defaultMode` | | `ignore`: matched row skipped, missing row inserted | `packages/metadata-protocol/src/seed-loader.ts:3298-3304` (`writeRecord`), `:3355-3356` (`decideWriteAction`) | | `upsert`: matched row updated with the declared columns (skipped only on a no-op), missing row inserted | `seed-loader.ts:3284-3296`, `:3350-3354` | | `insert`: inserts every record, no match | `seed-loader.ts:3267-3270`, `:3357-3360` | | `update`: matched row updated, missing row skipped | `seed-loader.ts:3272-3282`, `:3345-3349` | | `replace` deletes nothing: it inserts, "caller should have cleared the table" | `seed-loader.ts:3306-3310`, `:3358-3360`; no `'replace'` branch anywhere in `packages/metadata-protocol/src` or `packages/runtime/src` clears a table | | The record of landed keys is the organization-owned `sys_seed_landed_key` (cascades with the organization) | `packages/platform-objects/src/system/sys-seed-landed-key.object.ts:47`, `:88` | | Only `upsert` / `ignore` datasets read and write it | `seed-loader.ts:1406-1408` | | A recorded key whose row is gone is not inserted again; a never-recorded key still lands | `seed-loader.ts:1946-1951` `isWithdrawnRow`; option doc `:671-690` | | A reference to the removed row is left unwritten, not reported unresolved | `seed-loader.ts:2701` (`resolveReferenceItem` answers `withdrawn` via `isWithdrawnSeedTarget`, `:4119-4122`), handled at `:2066` and `:2851` | | Loads that honour it: boot seed, per-organization replay, dev hot reload | `packages/runtime/src/app-plugin.ts:2098` (boot), `:1996` (replayer), `:2526` (hot reload), all through `seedLoaderOptionsFor` `:84-98` | | The boot seed is single-tenant; a multi-tenant boot skips it for the per-organization replay | `app-plugin.ts:2069` (`organizationWallActive`), `:2084` (`multi-tenant mode — skipping inline seed`) | | Loads that keep no record (no `landedKeys` option) | `packages/metadata-protocol/src/protocol-publish-batch.ts:103` (publish apply); `packages/runtime/src/domains/packages.ts:4729` (package install); `packages/cloud-connection/src/marketplace-install-local-plugin.ts:2982`, `:4768`, `:4880` (install-local loads); its fallback `seed-replayer` `:1689` (`registerSeedReplayerIntoKernel`, only when none is registered) | | No organization or no `sys_seed_landed_key`: no record, said once | `seed-loader.ts:4051-4070` | | The deliberate re-seed: kernel service `seed-replayer`, `(organizationId, { reseed: true })` | `app-plugin.ts:1963` (signature), `:1996` (`reseed === true` → `'reseed'`), `:2042` → `packages/runtime/src/seed-datasets.ts:41` service name; callable by server code holding a plugin context (`ctx.getService`), e.g. `packages/plugins/organizations/src/organizations-plugin.ts:408`; it has no HTTP route | | A row deleted before this release comes back once, then is remembered | PR #31083 body, "Boundaries, by design"; the first load records every row still present (`seed-loader.ts:2491` `recordLandedKeys`) | ## Acceptance notes ### Token budget (`node scripts/check-skills-token-ratchet.mjs`, exit 0) | File | `main` | head | Ceiling | |---|---|---|---| | `skills/objectstack-data/references/seeds.md` | 1566 | 1560 | 1566 (unchanged) | | `skills/objectstack-data/SKILL.md` | 6078 | 6078 (not touched) | 6078 | | `skills/objectstack-data/evals/hooks-security-seeds-datasources.json` | 1753 | 1786, measured, not ratcheted (eval fixture) | — | No ceiling moves; the ratchet script is not touched. Every added line in `seeds.md` is at most 120 bytes (longest: the `replace` row, 119 B; `ignore` row 111 B, `upsert` row 106 B). The corrected rows plus the landed-keys lines were paid by deleting restatement in the same file: | Deleted | Surviving home | |---|---| | Intro: "`defineSeed()` is type-safe: TypeScript checks every record's field keys against the object definition." | Same file, Seed best practices row "Always use `defineSeed()`, never `SeedSchema.parse()` — Lose compile-time field checking otherwise" | | `applyOn` intro: "A seed is replayed (every dev-server boot, every re-publish)" | Same file, the line under the modes table: "A seed replays on each boot, install and re-publish." | | `applyOn` intro: "right for master data," | The modes table, `upsert` row: "Reference data kept current" | | `applyOn` parenthesis: the mode-by-mode restatement, shortened to "`update` never writes it; no other mode updates a matched row" | The modes table rows for `ignore` / `insert` / `replace` | | Determinism gate: "CEL + pinned `now` is what guarantees that — using `Date.now()` will fail CI." | Same file, Dynamic values (CEL): "This is the only correct way … `new Date()` ships the package author's clock to every customer and breaks build determinism." | | Quick start `categorySeed`: `mode: 'upsert',` | Not restatement: removed as a correction (demo data takes the default). | ### Review fixes (contract review 6703924081) - M1: `seed-data.mdx` names the loads that keep no record, so a deleted row comes back on a publish or a package install or upgrade, and says the boot seed is the single-tenant boot. The `ignore` row's "(unless a person deleted it)" now reads "(a boot or organization replay skips one a person deleted)". - S1: the mdx names the marketplace fallback `seed-replayer`, a load with no organization, and a composition without `sys_seed_landed_key`. `seeds.md` scopes its sentence to "AppPlugin's boot and organization replays (not a publish or package install)". - S2: `seeds.md` says again when a seed replays: "each boot, install and re-publish". - S3: eval case 4's demo dataset now declares `upsert` (prompt and `expected_output`); the `must_contain` list is unchanged and live. - S4: done by the seat (#31099); the body links it. - S5: the `ignore` and `upsert` rows are 111 B and 106 B. - N1: the mdx `upsert` row links the rule too. N2: the `externalId` row says "every keyed mode matches on". ### Sweep hits Grep for `upsert (default)`, `default.*upsert` and `replace.*delete` (plus the seed `mode` claims by hand) over `skills/objectstack-data/SKILL.md`, `rules/*.md`, `evals/*.json` and `references/`: - `references/seeds.md:51` (`externalId` "upsert / update lookup", N2), `:52` (`mode` default), `:63` (`upsert` (default)), `:64` (`insert` "fail on duplicate"), `:66` (`ignore` "silently skip duplicates"), `:67` (`replace` "Delete everything"), `:160` ("Default to `upsert`"), `:163` (`replace` "Data-loss footgun"): fixed. - `references/seeds.md:36` (demo seed `mode: 'upsert'`) and `:82-86` (`applyOn` example without `mode`): fixed, as above. `:26` (reference data `mode: 'upsert'`): correct, left. - `evals/hooks-security-seeds-datasources.json` case 4: implicit old default (it required `applyOn: 'create'` on a dataset with no `mode`), fixed as above. - `SKILL.md`, `rules/*.md`, `evals/objects-fields-relationships.json`: no hit. (`SKILL.md:104` "derived verbs (search/export/upsert/…)" is the API verb, not a seed mode.) Other `content/docs` pages that restate seed modes: the generated reference pages (`references/data/seed.mdx:33`, `references/data/seed-loader.mdx:198`, `references/api/package-api.mdx:246`, `references/kernel/manifest.mdx:100`) already say `ignore` is the default and `replace` inserts into a table the caller cleared; they pick up the spec wording below when it lands. No hand-written page outside `seed-data.mdx` restates them wrongly. Releases pages were not read for edits (release-owned). Outside both lanes, found while measuring: `packages/cli/src/commands/diff.ts:81` still says `replace` means "a replay deletes every row of the object and inserts these". It is CLI output text, carried on #31099; not touched here. ### Spec wording owed (spec lane, item 3, filed as #31099; not edited here) `packages/spec/src/data/seed.zod.ts`: - `:27` enum comment `'replace', // Delete ALL records in object then insert (Dangerous - use for cache tables)` → e.g. `// Insert with no match; the loader deletes nothing, the caller clears the table (cache tables)`. - `:24` enum comment `'insert', // Try to insert, fail on duplicate` → e.g. `// Insert every record with no match: a replay duplicates rows unless a unique index refuses them`. - `:171-172` docstring "a load inserts the records whose row is missing" and `:195-196` `.describe()` "a missing row is inserted and an existing row is left as it is" → add: a missing row whose key already landed in the organization (a person removed it) is not inserted again by AppPlugin's loads (boot, per-organization replay, dev hot reload); the `seed-replayer` service with `{ reseed: true }` inserts it. - `:116-117` the strict-object `history` string "on the defaults (`mode: upsert`, …)" describes the default at the time; accurate as history, optional to annotate. ## Tests None: the diff is teaching text and one eval expectation, with no runtime behaviour to test. The gates below are the checks. ## Verification <details><summary>Local gates on b739914: 68 derived, 68 run with exit 0, 0 NOT MEASURED (`dispatch-gates --ran`)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands (43 pnpm, 25 direct node) for the 4 changed paths. Each ran under a 900 s timeout with `NODE_OPTIONS=--max-old-space-size=8192`, its exit code captured before any pipe, over the full `pnpm build` (exit 0) made at 6ed361c (the review commit touches no built package). All 68 exited 0 at 6ed361c and again at b739914. - They include `check-skills-token-ratchet.mjs`, `check:skill-examples`, `check:skill-refs`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:doc-authoring`, `check:doc-anchors`, `check:docs`, `check:dts-closure`, `check:dual-build-cjs-loads`, `check-changeset-no-major.mjs`, `check-empty-changeset.mjs` and `check-comment-mask-corpus.mjs`. - `--ran` printed: `68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)`. - Also run, outside the derived list: `pnpm check:changeset-fixed` (exit 0), the one artifact-roster family whose roster sits under `.changeset`. </details> - `node scripts/pm/check-governed-merges.mjs --branch claude/project-thread-ba8x76`: GOVERNED, Tier H (人合): `skills/**`; 86 changed lines (+63 / −23), under 3000. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0 before each push. - `main` moved after the branch point; none of its commits touches these files or the ratchet. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema touched.) - [x] 2. Every name I added uses the casing for its kind. (No names added.) - [x] 3. No formatter ran. (Markdown, MDX and one JSON string only.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. `skills/**` publishes `@objectstack/skills`, so the PR carries a `'@objectstack/skills': patch` changeset. Both declaration lines are filled in above. - [x] 6. `packages/spec` not touched; nothing to regenerate. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 section is Chinese, per the lane template.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAkA4qny8vRgP2NzRgs <!-- signed: domain:skills#3 · huangyiirene · session_01J8VVAj7CvjkGXvgXzd6RZ7 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad06144 commit 2178d80

4 files changed

Lines changed: 63 additions & 23 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the objectstack-data seeds reference now teaches the seed modes the loader runs: `ignore` is the default (create-only), `replace` deletes nothing (it inserts into a table the caller cleared), and `insert` duplicates on each replay. It also says that AppPlugin's boot and organization replays do not re-insert a seed row a person deleted (a publish or package install keeps no record, so the row comes back there), and that `seed-replayer` with `{ reseed: true }` restores it. The data-modeling seed guide teaches the same rule and names the loads that keep no record.
6+
7+
Clause-②: no
8+
Metadata-protocol: none

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ customer has since edited.
7171

7272
| Mode | Behavior | Use Case |
7373
|:-----|:---------|:---------|
74-
| `ignore` | **Default.** Create-only: insert a record whose row is missing, leave an existing row exactly as it is | Business and starter data the customer owns once it is loaded |
75-
| `upsert` | Create if new, and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current |
74+
| `ignore` | **Default.** Create-only: insert a record whose row is missing (a boot or organization replay skips one a person [deleted](#a-deleted-seed-row-stays-deleted)), leave an existing row exactly as it is | Business and starter data the customer owns once it is loaded |
75+
| `upsert` | Create if new (a boot or organization replay skips one a person [deleted](#a-deleted-seed-row-stays-deleted)), and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current |
7676
| `insert` | Insert **every** record unconditionally — no existing-row check, so it **duplicates the table on each replay boot** ([#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). Rarely what you want | Append-only rows loaded exactly once (never re-run) |
7777
| `update` | Update only, skip if not found | Migration patches on existing rows |
7878
| `replace` | Insert without checking for an existing match — the seed loader does **not** delete anything itself; the target table must already be empty (or cleared by the caller) | Cache / lookup tables rebuilt from an already-truncated table |
@@ -153,6 +153,38 @@ check**, so a replayed `insert` dataset duplicates its table on each restart
153153
It is almost never the right mode: leave `mode` unset (create-only), or declare
154154
`upsert` for reference data, with a stable `externalId` instead. `os validate` warns on any `mode: 'insert'` seed.
155155

156+
### A Deleted Seed Row Stays Deleted
157+
158+
Each organization records which seed rows landed in it, by `externalId`, in the
159+
platform object `sys_seed_landed_key`. When a person deletes a seeded row, the
160+
next boot or organization replay does not insert it again: an `ignore` or
161+
`upsert` dataset inserts a missing row only if its key never landed in that
162+
organization. A row an upgrade adds to the seed has never landed, so it
163+
is still inserted. A reference to the deleted row from another seed row is left
164+
unwritten rather than reported as an error.
165+
166+
The record is kept and honoured only where AppPlugin runs the load for a known
167+
organization: the single-tenant boot seed (`objectstack serve`, `os start`, on
168+
every restart; a multi-tenant boot seeds through the per-organization replay
169+
instead), the per-organization replay (`seed-replayer`), and the dev hot reload.
170+
A seed publish, a package install or upgrade (`os package install`, the
171+
marketplace install, its heal and its sample-data reseed) keep no record, so a
172+
deleted row comes back on those. So do a load with no organization, a
173+
composition that does not register `sys_seed_landed_key`
174+
(`@objectstack/platform-objects`), and the marketplace's fallback
175+
`seed-replayer`, which it registers when no app has seed data.
176+
177+
A row deleted before your runtime had this rule comes back once, on the first
178+
replay after the upgrade, and stays deleted from then on.
179+
180+
To bring the deleted rows back on purpose, a host or plugin calls the
181+
`seed-replayer` kernel service with `{ reseed: true }`:
182+
183+
```typescript
184+
const replay = ctx.getService('seed-replayer'); // ctx: the plugin's context
185+
await replay(organizationId, { reseed: true }); // inserts every missing seed row
186+
```
187+
156188
---
157189

158190
## Seeds and State Machines

‎skills/objectstack-data/evals/hooks-security-seeds-datasources.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
},
3434
{
3535
"id": 4,
36-
"prompt": "Seed the `country` table for every environment; add demo `opportunity` rows (dev/test only) closing 45 days after install, owned by the installer. A nightly job advances `stage`; a seed replay must not reset it.",
37-
"expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; `columns: { stage: { applyOn: 'create' } }` so an `upsert` replay leaves the job-owned column.",
36+
"prompt": "Seed the `country` table for every environment; add demo `opportunity` rows (dev/test only) closing 45 days after install, owned by the installer, and refreshed from the seed on every replay except `stage`: a nightly job advances it, and a replay must not reset it.",
37+
"expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { mode: 'upsert', env: ['dev', 'test'], ... })` for the refreshed demo rows — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; `columns: { stage: { applyOn: 'create' } }` so the `upsert` replay leaves the job-owned column (the `ignore` default would refresh nothing).",
3838
"files": [],
3939
"assertions": {
4040
"must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"],

‎skills/objectstack-data/references/seeds.md‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
Object definition and seed data live together — a `*.object.ts` usually
44
pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data).
5-
`defineSeed()` is type-safe: TypeScript checks every record's field keys
6-
against the object definition.
75

86
> The factory is `defineSeed` — **not** `defineDataset`, which is the
97
> unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`).
@@ -33,7 +31,6 @@ export const statusSeed = defineSeed(Status, {
3331
// Demo data — dev/test only
3432
export const categorySeed = defineSeed(Category, {
3533
externalId: 'slug',
36-
mode: 'upsert',
3734
env: ['dev', 'test'],
3835
records: [
3936
{ slug: 'electronics', name: 'Electronics' },
@@ -48,8 +45,8 @@ export const SeedData = [statusSeed, categorySeed]; // parents first
4845
| Field | Default | Purpose |
4946
|:------|:--------|:--------|
5047
| `object` | derived | Auto-set from `objectDef.name` — never write manually |
51-
| `externalId` | `'name'` | Stable business key used for upsert / update lookup |
52-
| `mode` | `'upsert'` | Import strategy (see below) |
48+
| `externalId` | `'name'` | Stable business key every keyed mode matches on |
49+
| `mode` | `'ignore'` | Import strategy (see below) |
5350
| `columns` | — | Per-column write policy, keyed by field: `{ FIELD: { applyOn: 'create' } }` (below) |
5451
| `env` | `['prod','dev','test']` | Environments where the seed loads |
5552
| `records` | — | `Partial<Record<keyof object.fields, unknown>>[]` |
@@ -60,27 +57,31 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`.
6057

6158
| Mode | Behavior | Use for |
6259
|:-----|:---------|:--------|
63-
| `upsert` (default) | Update by `externalId`, insert if missing. Idempotent. | Reference data, bootstrap rows |
64-
| `insert` | Insert all; fail on duplicate `externalId`. | Append-only / audit tables |
60+
| `ignore` (default) | Create-only: insert a missing row, leave a matched row as is. | Data the customer owns |
61+
| `upsert` | Insert if missing; every replay writes declared columns back. | Reference data kept current |
62+
| `insert` | Insert every record, no match: duplicates on each replay. | Rows loaded once |
6563
| `update` | Update only existing rows; never create. | Patching existing config |
66-
| `ignore` | Insert; silently skip duplicates. | Additive bootstrap |
67-
| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data |
64+
| `replace` ⚠️ | Insert with no match; deletes nothing, the caller clears the table. | Cache / lookup tables only |
65+
66+
A seed replays on each boot, install and re-publish. AppPlugin's boot and organization
67+
replays (not a publish or package install) do not re-insert a keyed row a
68+
person deleted; `seed-replayer` with `{ reseed: true }` restores it.
6869

6970
### Columns the app owns after the first load — `columns.FIELD.applyOn`
7071

71-
A seed is replayed (every dev-server boot, every re-publish) and an `upsert`
72-
replay re-asserts every column on the row it matched — right for master data,
73-
wrong for a column a job or an admin advances after the first load. Declare it
72+
An `upsert` replay re-asserts every column on the row it matched — wrong for
73+
a column a job or an admin advances after the first load. Declare it
7474
`applyOn: 'create'`: written when the seed creates the row, left as the row
75-
holds it on every replay (an `update` dataset never writes it; `insert` /
76-
`ignore` / `replace` never update a matched row, so nothing changes there).
75+
holds it on every replay (`update` never writes it; no other mode updates a
76+
matched row).
7777
Each key must be a field of the object or an injected system column —
7878
`defineSeed` refuses one that is not. Same values and meaning as an import
7979
mapping entry's `applyOn` (objectstack-api).
8080

8181
```typescript
8282
defineSeed(Invoice, {
8383
externalId: 'number',
84+
mode: 'upsert',
8485
columns: { status: { applyOn: 'create' } }, // the payment job owns it after the first seed
8586
records: [{ number: 'INV-001', status: 'draft', amount: 1200 }],
8687
});
@@ -147,17 +148,16 @@ Stdlib in seed context: `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`,
147148
`isBlank(v)`, `coalesce(v, fallback)`. Scope: `os.user`, `os.org`, `os.env`.
148149
See **objectstack-formula** for the full contract.
149150

150-
**Determinism gate:** two consecutive `os build` runs with no source
151-
changes must produce byte-identical `dist/objectstack.json`. CEL + pinned
152-
`now` is what guarantees that — using `Date.now()` will fail CI.
151+
**Determinism gate:** two `os build` runs with no source change must
152+
produce byte-identical `dist/objectstack.json`.
153153

154154
### Seed best practices
155155

156156
| Practice | Why |
157157
|:---------|:----|
158158
| Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise |
159159
| Prefer natural keys (`code` / `email` / `slug`) | Portable across environments |
160-
| Default to `upsert` | Idempotent re-runs |
160+
| Leave `mode` unset; `upsert` only for reference data | Replays keep customer edits |
161161
| Scope demo data with `env: ['dev','test']` | Keep noise out of prod |
162162
| Order seeds parent → child in the exported array | References resolve at load time |
163-
| Use `replace` only on cache/lookup tables, with comments | Data-loss footgun |
163+
| `replace` only on cache tables the caller clears | It never deletes: duplicates otherwise |

0 commit comments

Comments
 (0)