From 6ed361c83cabbb5b93fe8d3ee59e3c60dac3c844 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 06:57:18 +0000 Subject: [PATCH 1/2] docs(skills): teach the seed modes the loader runs and that a deleted seed row stays deleted The objectstack-data seeds reference called upsert the default and said replace deletes every row first. The loader defaults to ignore (create-only) and replace deletes nothing. The reference, its seed eval and the data-modeling seed guide now say so, and teach that a boot or organization replay does not re-insert a seed row a person deleted, with seed-replayer { reseed: true } as the way to restore it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkA4qny8vRgP2NzRgs --- .../skills-data-seed-modes-landed-keys.md | 8 +++++ content/docs/data-modeling/seed-data.mdx | 24 ++++++++++++- .../hooks-security-seeds-datasources.json | 2 +- skills/objectstack-data/references/seeds.md | 36 +++++++++---------- 4 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 .changeset/skills-data-seed-modes-landed-keys.md 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..d2f69ce7735 --- /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 a boot or organization replay does not re-insert a seed row a person deleted, and that `seed-replayer` with `{ reseed: true }` restores it. The data-modeling seed guide teaches the same rule. + +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..f91372cfd1e 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -71,7 +71,7 @@ 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 | +| `ignore` | **Default.** Create-only: insert a record whose row is missing (unless a person [deleted it](#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, 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 | @@ -153,6 +153,28 @@ 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 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 honoured by the boot seed (`objectstack serve`, `os start`, so +every restart and upgrade), the per-organization replay, and the dev hot +reload. 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..539179b161d 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -34,7 +34,7 @@ { "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.", + "expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows with no `mode` (the `ignore` default never writes a matched row, so a replay leaves `stage`) — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; on an `upsert` dataset, `columns: { stage: { applyOn: 'create' } }` leaves the job-owned column.", "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..5ac14bf760c 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' }, @@ -49,7 +46,7 @@ export const SeedData = [statusSeed, categorySeed]; // parents first |:------|:--------|:--------| | `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) | +| `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 once loaded | +| `upsert` | Insert if missing; every replay writes the declared columns back. | Reference data the package keeps 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 boot or organization replay does not re-insert a keyed row a person deleted +(landed keys are recorded per organization); `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 | From b7399145351eb7d9fe35d157fde4127a403f12c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 07:31:27 +0000 Subject: [PATCH 2/2] docs(skills): scope the deleted-seed-row rule to the loads that keep the record The landed-keys rule holds only where AppPlugin runs the load for a known organization. The seed guide now names the loads that keep no record (a publish, a package install or upgrade, the marketplace fallback replayer, a load with no organization or no sys_seed_landed_key), and the seeds reference scopes its sentence the same way and says again when a seed replays. Its two long table rows are trimmed under 120 bytes, the externalId row names every keyed mode, and the seed eval's demo dataset declares upsert so applyOn 'create' is live. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkA4qny8vRgP2NzRgs --- .../skills-data-seed-modes-landed-keys.md | 2 +- content/docs/data-modeling/seed-data.mdx | 26 +++++++++++++------ .../hooks-security-seeds-datasources.json | 4 +-- skills/objectstack-data/references/seeds.md | 12 ++++----- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.changeset/skills-data-seed-modes-landed-keys.md b/.changeset/skills-data-seed-modes-landed-keys.md index d2f69ce7735..077bdaf432c 100644 --- a/.changeset/skills-data-seed-modes-landed-keys.md +++ b/.changeset/skills-data-seed-modes-landed-keys.md @@ -2,7 +2,7 @@ '@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 a boot or organization replay does not re-insert a seed row a person deleted, and that `seed-replayer` with `{ reseed: true }` restores it. The data-modeling seed guide teaches the same rule. +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 f91372cfd1e..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 (unless a person [deleted it](#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, 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 | @@ -157,15 +157,25 @@ It is almost never the right mode: leave `mode` unset (create-only), or declare 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 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 +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 honoured by the boot seed (`objectstack serve`, `os start`, so -every restart and upgrade), the per-organization replay, and the dev hot -reload. 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. +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 }`: diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index 539179b161d..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 with no `mode` (the `ignore` default never writes a matched row, so a replay leaves `stage`) — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; on an `upsert` dataset, `columns: { stage: { applyOn: 'create' } }` 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 5ac14bf760c..2be06778066 100644 --- a/skills/objectstack-data/references/seeds.md +++ b/skills/objectstack-data/references/seeds.md @@ -45,7 +45,7 @@ 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 | +| `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 | @@ -57,15 +57,15 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. | Mode | Behavior | Use for | |:-----|:---------|:--------| -| `ignore` (default) | Create-only: insert a missing row, leave a matched row as is. | Data the customer owns once loaded | -| `upsert` | Insert if missing; every replay writes the declared columns back. | Reference data the package keeps current | +| `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 | | `replace` ⚠️ | Insert with no match; deletes nothing, the caller clears the table. | Cache / lookup tables only | -A boot or organization replay does not re-insert a keyed row a person deleted -(landed keys are recorded per organization); `seed-replayer` with -`{ reseed: true }` restores it. +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`