From c997fd6deecb955599b241b18b54b64407724568 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:09:10 +0000 Subject: [PATCH 1/3] docs(spec,cli): seed replace deletes nothing, and the AppPlugin loads skip a landed row a person deleted Claude-Session: https://claude.ai/code/session_017ihL2dzed5sJnRTmwXHWV7 Co-Authored-By: Claude --- .../spec-cli-seed-replace-deletes-nothing.md | 9 ++++++++ packages/cli/src/commands/diff.ts | 2 +- packages/cli/test/diff-upgrade-plan.test.ts | 2 +- packages/spec/src/data/seed.zod.ts | 22 ++++++++++++++++--- 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 .changeset/spec-cli-seed-replace-deletes-nothing.md diff --git a/.changeset/spec-cli-seed-replace-deletes-nothing.md b/.changeset/spec-cli-seed-replace-deletes-nothing.md new file mode 100644 index 00000000000..712b42a73b8 --- /dev/null +++ b/.changeset/spec-cli-seed-replace-deletes-nothing.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +docs(spec,cli): the seed mode text now says what the loader does. `replace` deletes nothing: it inserts every record with no match, for a table the caller cleared, and `insert` inserts every record again on each replay. The `mode` docstring and `.describe()` name the landed-keys rule: the loads AppPlugin runs for an organization (the single-tenant boot seed, the `seed-replayer` replay, the dev hot reload) do not insert again an `ignore` or `upsert` row that landed once and was later deleted, a seed publish or package install keeps no such record, and `seed-replayer(organizationId, { reseed: true })` inserts every missing row. `os diff`'s note on a `replace` dataset no longer says a replay deletes every row of the object. + +Clause-②: no +Metadata-protocol: none diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index bbe219fef65..5a8ab9b986c 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -78,7 +78,7 @@ function diffNamedArrays( function seedModeNote(mode: string): string { if (seedModeOverwritesMatchedRows(mode)) return '; a replay writes its columns over the rows it matches'; - if (mode === 'replace') return '; a replay deletes every row of the object and inserts these'; + if (mode === 'replace') return '; a replay deletes nothing and inserts these again, so rows already in the table stay'; return ''; } diff --git a/packages/cli/test/diff-upgrade-plan.test.ts b/packages/cli/test/diff-upgrade-plan.test.ts index 5a59479d104..8bf07d95b24 100644 --- a/packages/cli/test/diff-upgrade-plan.test.ts +++ b/packages/cli/test/diff-upgrade-plan.test.ts @@ -144,7 +144,7 @@ describe('os diff — what the upgrade plan adds', () => { { type: 'removed', category: 'data', name: 'acme_old', breaking: false, detail: 'seed dataset removed, mode insert' }, { type: 'added', category: 'data', name: 'acme_tier', breaking: false, - detail: 'seed dataset added: 1 record(s); mode replace; a replay deletes every row of the object and inserts these', + detail: 'seed dataset added: 1 record(s); mode replace; a replay deletes nothing and inserts these again, so rows already in the table stay', }, { type: 'modified', category: 'data', name: 'acme_stage', breaking: false, diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 346ba8dfc8d..d47b47a64f1 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -21,10 +21,10 @@ import { isSeedFileReference, } from './seed-file.zod'; export const SeedMode = z.enum([ - 'insert', // Try to insert, fail on duplicate + 'insert', // Insert every record, no match: each replay inserts them again (a row the database refuses fails) 'update', // Only update found records, ignore new 'upsert', // Create new or update existing: every replay writes the declared columns back - 'replace', // Delete ALL records in object then insert (Dangerous - use for cache tables) + 'replace', // Insert every record, no match, deleting nothing: for a cache table the caller cleared 'ignore' // Create-only (the default): insert missing rows, leave existing rows alone ]); @@ -177,6 +177,18 @@ export const SeedSchema = lazySchema(() => strictObject({ * (ruling 6631016988 on objectstack#30081, which measured exactly that on a * production boot and on both upgrade paths while the default was `upsert`). * + * A row a person deleted is not brought back by every replay. The loads + * AppPlugin (`@objectstack/runtime`) runs for an organization — the + * single-tenant boot seed, the per-organization replay (the `seed-replayer` + * service) and the dev hot reload — record, per organization, the key of + * every row an `ignore` or `upsert` dataset landed, and do not insert again + * a recorded row that is now gone. A key never recorded still lands, so a + * row an upgrade adds to the seed is inserted. A seed publish and a package + * install or upgrade (`os package install`, the marketplace install) keep no + * record, so the row comes back on those. The deliberate re-seed, + * `seed-replayer(organizationId, { reseed: true })`, inserts every missing + * row whatever the record says. + * * An author who wants a replay to refresh the rows — reference data the * package keeps current — declares `mode: 'upsert'`, and can still exempt * the columns the customer or a job owns after the first load with @@ -197,7 +209,11 @@ export const SeedSchema = lazySchema(() => strictObject({ '(every boot, a package install or upgrade, a publish) never writes a seeded value back over a ' + 'later edit. `upsert` also writes every declared column back onto the matched row on each replay; ' + 'declare it for reference data the package keeps current. `update` writes matched rows only, ' + - '`insert` inserts every record with no match, and `replace` inserts into a table the caller cleared.', + '`insert` inserts every record with no match, so each replay inserts them again, and `replace` does the ' + + 'same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an ' + + 'organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row ' + + 'that landed once and was later deleted is not inserted again; a publish or package install keeps no such ' + + 'record, and seed-replayer called with { reseed: true } inserts every missing row.', ), /** From ca38274b46416ba983686c38066cec32b522ba6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:12:38 +0000 Subject: [PATCH 2/3] docs(spec): regenerate references for the seed mode describe text Claude-Session: https://claude.ai/code/session_017ihL2dzed5sJnRTmwXHWV7 Co-Authored-By: Claude --- content/docs/references/api/package-api.mdx | 4 ++-- content/docs/references/data/seed-loader.mdx | 2 +- content/docs/references/data/seed.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 932782050e6..8863d5d6ff2 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -243,7 +243,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | | **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | -| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, so each replay inserts them again, and `replace` does the same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row that landed once and was later deleted is not inserted again; a publish or package install keeps no such record, and seed-replayer called with `{ reseed: true }` inserts every missing row. | | **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The boot seed filters on the app's i18n.defaultLocale; the marketplace install, sample-data reseed, restart heal and per-organization replay filter on the locale resolved for the load (the localization.locale setting when set, then the locale of the installing, reseeding or founding user, then the app's i18n.defaultLocale where the load names one); package apply and draft publish do not filter by locale — they load every dataset and warn | @@ -351,7 +351,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | | **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | -| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, so each replay inserts them again, and `replace` does the same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row that landed once and was later deleted is not inserted again; a publish or package install keeps no such record, and seed-replayer called with `{ reseed: true }` inserts every missing row. | | **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The boot seed filters on the app's i18n.defaultLocale; the marketplace install, sample-data reseed, restart heal and per-organization replay filter on the locale resolved for the load (the localization.locale setting when set, then the locale of the installing, reseeding or founding user, then the app's i18n.defaultLocale where the load names one); package apply and draft publish do not filter by locale — they load every dataset and warn | diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index c335792fcd9..09230fd0892 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -230,7 +230,7 @@ Seed loader request with datasets and configuration | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | | **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | -| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, so each replay inserts them again, and `replace` does the same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row that landed once and was later deleted is not inserted again; a publish or package install keeps no such record, and seed-replayer called with `{ reseed: true }` inserts every missing row. | | **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The boot seed filters on the app's i18n.defaultLocale; the marketplace install, sample-data reseed, restart heal and per-organization replay filter on the locale resolved for the load (the localization.locale setting when set, then the locale of the installing, reseeding or founding user, then the app's i18n.defaultLocale where the load names one); package apply and draft publish do not filter by locale — they load every dataset and warn | diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx index 646ed74badc..10834427e5c 100644 --- a/content/docs/references/data/seed.mdx +++ b/content/docs/references/data/seed.mdx @@ -30,7 +30,7 @@ const result = SeedSchema.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | | **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | -| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, so each replay inserts them again, and `replace` does the same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row that landed once and was later deleted is not inserted again; a publish or package install keeps no such record, and seed-replayer called with `{ reseed: true }` inserts every missing row. | | **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The boot seed filters on the app's i18n.defaultLocale; the marketplace install, sample-data reseed, restart heal and per-organization replay filter on the locale resolved for the load (the localization.locale setting when set, then the locale of the installing, reseeding or founding user, then the app's i18n.defaultLocale where the load names one); package apply and draft publish do not filter by locale — they load every dataset and warn | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index fd124280ae1..cd1fcaf1df9 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -97,7 +97,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | | **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | -| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, so each replay inserts them again, and `replace` does the same and deletes nothing: it is for a table the caller cleared. On the loads AppPlugin runs for an organization (the boot seed, the seed-replayer replay, the dev hot reload), an `ignore` or `upsert` row that landed once and was later deleted is not inserted again; a publish or package install keeps no such record, and seed-replayer called with `{ reseed: true }` inserts every missing row. | | **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The boot seed filters on the app's i18n.defaultLocale; the marketplace install, sample-data reseed, restart heal and per-organization replay filter on the locale resolved for the load (the localization.locale setting when set, then the locale of the installing, reseeding or founding user, then the app's i18n.defaultLocale where the load names one); package apply and draft publish do not filter by locale — they load every dataset and warn | From 89162ffbf8f8c8fef0be456c8742690c5fc59ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:18:48 +0000 Subject: [PATCH 3/3] docs(spec): the landed-keys paragraph names the sys_seed_landed_key condition and keyed rows Claude-Session: https://claude.ai/code/session_017ihL2dzed5sJnRTmwXHWV7 Co-Authored-By: Claude --- packages/spec/src/data/seed.zod.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index d47b47a64f1..4aa3958ab90 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -21,7 +21,7 @@ import { isSeedFileReference, } from './seed-file.zod'; export const SeedMode = z.enum([ - 'insert', // Insert every record, no match: each replay inserts them again (a row the database refuses fails) + 'insert', // Insert every record, no match: each replay inserts them again (a row the database refuses is that row's error) 'update', // Only update found records, ignore new 'upsert', // Create new or update existing: every replay writes the declared columns back 'replace', // Insert every record, no match, deleting nothing: for a cache table the caller cleared @@ -180,10 +180,12 @@ export const SeedSchema = lazySchema(() => strictObject({ * A row a person deleted is not brought back by every replay. The loads * AppPlugin (`@objectstack/runtime`) runs for an organization — the * single-tenant boot seed, the per-organization replay (the `seed-replayer` - * service) and the dev hot reload — record, per organization, the key of - * every row an `ignore` or `upsert` dataset landed, and do not insert again - * a recorded row that is now gone. A key never recorded still lands, so a - * row an upgrade adds to the seed is inserted. A seed publish and a package + * service) and the dev hot reload, where the composition registers + * `sys_seed_landed_key` (`@objectstack/platform-objects`) — record, per + * organization, the key of every keyed row an `ignore` or `upsert` dataset + * landed, and do not insert again a recorded row that is now gone. A key + * never recorded still lands, so a row an upgrade adds to the seed is + * inserted. A seed publish and a package * install or upgrade (`os package install`, the marketplace install) keep no * record, so the row comes back on those. The deliberate re-seed, * `seed-replayer(organizationId, { reseed: true })`, inserts every missing