Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/spec-cli-seed-replace-deletes-nothing.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions content/docs/references/api/package-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { applyOn?: Enum<'create' \| 'always'> }>` | 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 |
Expand Down Expand Up @@ -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<string, { applyOn?: Enum<'create' \| 'always'> }>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/seed-loader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { applyOn?: Enum<'create' \| 'always'> }>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/seed.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { applyOn?: Enum<'create' \| 'always'> }>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { applyOn?: Enum<'create' \| 'always'> }>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/diff-upgrade-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 21 additions & 3 deletions packages/spec/src/data/seed.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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', // 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
]);

Expand Down Expand Up @@ -177,6 +177,20 @@ 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, 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
* 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
Expand All @@ -197,7 +211,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.',
),

/**
Expand Down
Loading