diff --git a/.changeset/skills-data-seed-modes-landed-keys.md b/.changeset/skills-data-seed-modes-landed-keys.md new file mode 100644 index 00000000000..077bdaf432c --- /dev/null +++ b/.changeset/skills-data-seed-modes-landed-keys.md @@ -0,0 +1,8 @@ +--- +'@objectstack/skills': patch +--- + +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. + +Clause-②: no +Metadata-protocol: none diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 6f2257287e8..293e133d5f7 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -71,8 +71,8 @@ customer has since edited. | Mode | Behavior | Use Case | |:-----|:---------|:---------| -| `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 | -| `upsert` | Create if new, and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current | +| `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 | +| `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 | | `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) | | `update` | Update only, skip if not found | Migration patches on existing rows | | `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 It is almost never the right mode: leave `mode` unset (create-only), or declare `upsert` for reference data, with a stable `externalId` instead. `os validate` warns on any `mode: 'insert'` seed. +### A Deleted Seed Row Stays Deleted + +Each organization records which seed rows landed in it, by `externalId`, in the +platform object `sys_seed_landed_key`. When a person deletes a seeded row, the +next boot or organization replay does not insert it again: an `ignore` or +`upsert` dataset inserts a missing row only if its key never landed in that +organization. A row an upgrade adds to the seed has never landed, so it +is still inserted. A reference to the deleted row from another seed row is left +unwritten rather than reported as an error. + +The record is kept and honoured only where AppPlugin runs the load for a known +organization: the single-tenant boot seed (`objectstack serve`, `os start`, on +every restart; a multi-tenant boot seeds through the per-organization replay +instead), the per-organization replay (`seed-replayer`), and the dev hot reload. +A seed publish, a package install or upgrade (`os package install`, the +marketplace install, its heal and its sample-data reseed) keep no record, so a +deleted row comes back on those. So do a load with no organization, a +composition that does not register `sys_seed_landed_key` +(`@objectstack/platform-objects`), and the marketplace's fallback +`seed-replayer`, which it registers when no app has seed data. + +A row deleted before your runtime had this rule comes back once, on the first +replay after the upgrade, and stays deleted from then on. + +To bring the deleted rows back on purpose, a host or plugin calls the +`seed-replayer` kernel service with `{ reseed: true }`: + +```typescript +const replay = ctx.getService('seed-replayer'); // ctx: the plugin's context +await replay(organizationId, { reseed: true }); // inserts every missing seed row +``` + --- ## Seeds and State Machines diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index cd8ef423aea..c28441d8693 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -33,8 +33,8 @@ }, { "id": 4, - "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.", - "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.", + "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.", + "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).", "files": [], "assertions": { "must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"], diff --git a/skills/objectstack-data/references/seeds.md b/skills/objectstack-data/references/seeds.md index 57b592d18aa..2be06778066 100644 --- a/skills/objectstack-data/references/seeds.md +++ b/skills/objectstack-data/references/seeds.md @@ -2,8 +2,6 @@ Object definition and seed data live together — a `*.object.ts` usually pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data). -`defineSeed()` is type-safe: TypeScript checks every record's field keys -against the object definition. > The factory is `defineSeed` — **not** `defineDataset`, which is the > unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`). @@ -33,7 +31,6 @@ export const statusSeed = defineSeed(Status, { // Demo data — dev/test only export const categorySeed = defineSeed(Category, { externalId: 'slug', - mode: 'upsert', env: ['dev', 'test'], records: [ { slug: 'electronics', name: 'Electronics' }, @@ -48,8 +45,8 @@ export const SeedData = [statusSeed, categorySeed]; // parents first | Field | Default | Purpose | |:------|:--------|:--------| | `object` | derived | Auto-set from `objectDef.name` — never write manually | -| `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) | | `columns` | — | Per-column write policy, keyed by field: `{ FIELD: { applyOn: 'create' } }` (below) | | `env` | `['prod','dev','test']` | Environments where the seed loads | | `records` | — | `Partial>[]` | @@ -60,20 +57,23 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. | Mode | Behavior | Use for | |:-----|:---------|:--------| -| `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. ### Columns the app owns after the first load — `columns.FIELD.applyOn` -A seed is replayed (every dev-server boot, every re-publish) and an `upsert` -replay re-asserts every column on the row it matched — right for master data, -wrong for a column a job or an admin advances after the first load. Declare it +An `upsert` replay re-asserts every column on the row it matched — wrong for +a column a job or an admin advances after the first load. Declare it `applyOn: 'create'`: written when the seed creates the row, left as the row -holds it on every replay (an `update` dataset never writes it; `insert` / -`ignore` / `replace` never update a matched row, so nothing changes there). +holds it on every replay (`update` never writes it; no other mode updates a +matched row). Each key must be a field of the object or an injected system column — `defineSeed` refuses one that is not. Same values and meaning as an import mapping entry's `applyOn` (objectstack-api). @@ -81,6 +81,7 @@ mapping entry's `applyOn` (objectstack-api). ```typescript defineSeed(Invoice, { externalId: 'number', + mode: 'upsert', columns: { status: { applyOn: 'create' } }, // the payment job owns it after the first seed records: [{ number: 'INV-001', status: 'draft', amount: 1200 }], }); @@ -147,9 +148,8 @@ Stdlib in seed context: `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(v, fallback)`. Scope: `os.user`, `os.org`, `os.env`. See **objectstack-formula** for the full contract. -**Determinism gate:** two consecutive `os build` runs with no source -changes must produce byte-identical `dist/objectstack.json`. CEL + pinned -`now` is what guarantees that — using `Date.now()` will fail CI. +**Determinism gate:** two `os build` runs with no source change must +produce byte-identical `dist/objectstack.json`. ### Seed best practices @@ -157,7 +157,7 @@ changes must produce byte-identical `dist/objectstack.json`. CEL + pinned |:---------|:----| | Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise | | Prefer natural keys (`code` / `email` / `slug`) | Portable across environments | -| Default to `upsert` | Idempotent re-runs | +| Leave `mode` unset; `upsert` only for reference data | Replays keep customer edits | | Scope demo data with `env: ['dev','test']` | Keep noise out of prod | | Order seeds parent → child in the exported array | References resolve at load time | -| 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 |