diff --git a/.changeset/30081-seed-mode-default-create-only.md b/.changeset/30081-seed-mode-default-create-only.md new file mode 100644 index 00000000000..e496783079b --- /dev/null +++ b/.changeset/30081-seed-mode-default-create-only.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +'@objectstack/metadata-protocol': minor +'@objectstack/cloud-connection': minor +--- + +feat(spec)!: a seed is create-only by default — an omitted `mode` is `ignore`, so a restart, an install or an upgrade never writes a seeded value back over a customer's edit (#30081) + +Clause-②: yes (narrowing) — the declared default of `Seed.mode` (and of `SeedLoaderConfig.defaultMode`, now the same field) moves from `upsert` to `ignore`. The accept set is unchanged and every authored `mode` parses exactly as before; what narrows is what a replay does for a dataset that declares no mode: it no longer updates an existing row. +Metadata-protocol: semantic + + + +**BREAKING** for a seed dataset that declares no `mode` and relies on a replay to write its values back onto existing rows. + +- **Before.** A seed with no `mode` replayed as `upsert`. A seed loads again on every single-tenant boot (production included, under `os start` and `os serve`), on every package install or upgrade and on every publish, so each replay wrote the shipped value back over a customer's edit to a seeded row, with no warning. Measured on a production boot before the change (card comment 6633189577): an API edit to a seeded row went back to the shipped value on the next restart, after a boot-artifact upgrade, and after an `os package install` upgrade. +- **Now.** `SeedSchema.mode` defaults to `ignore`: a replay inserts a record whose row is missing and leaves an existing row exactly as it is. A row nobody edited is not rewritten either, so its `updated_at` does not move, and a value changed in a later seed version reaches only a row the load creates. Maintainer ruling 6631016988 (option A). +- **One default, stated once.** `SeedLoaderConfig.defaultMode` is `SeedSchema`'s own `mode` field, default included. The six replay sites (`@objectstack/runtime`: the inline boot seed, the per-organization replayer, the dev hot reload and the published-seed apply; `@objectstack/metadata-protocol`: the publish batch; `@objectstack/cloud-connection`: install, reseed and the rehydrate healer) stop restating `defaultMode: 'upsert'`, and the save door reads the schema's default for a seed item that declares no mode. +- **Compiled artifacts.** `os build` stores the parsed seed, so a package built before this change carries a written `mode: "upsert"` on every dataset. The loader cannot tell that value from an authored one, and nothing converts it: such a package keeps today's replay until it is rebuilt. +- **Installed packages.** A package installed with `os package install` (`@objectstack/cloud-connection`'s install-local) keeps the body it was installed with in its install ledger, and the sample-data reseed (`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`) replays that body's datasets in their stored mode. So an entry installed from a pre-change artifact keeps writing the shipped values back over edited rows on every reseed until the **rebuilt artifact is re-installed**; rebuilding alone changes nothing for it. An entry installed from a rebuilt artifact reseeds create-only: it puts back the rows that are missing and leaves an edited row as it is. The ledger's other replays never overwrite: the boot rehydrate seeds nothing, the heal seeds only when every seeded object is empty, and the per-organization replay runs only for a new organization. +- **Stored `seed` items.** A `seed` metadata item stored with no `mode` is not rewritten either. Its next publish parses it to `ignore`, so that publish inserts only the rows it is missing where it used to write its values back over existing rows, with no signal. +- **Unchanged.** An explicit `mode: 'upsert'` still writes every declared column back on each replay, and `columns: { FIELD: { applyOn: 'create' } }` still exempts the columns a customer or a job owns. `insert`, `update` and `replace` behave as before. + +**The remedy.** Rebuild every package so its compiled artifact carries the mode its source declares, and re-install the rebuilt artifact wherever the package was installed with `os package install`, so its install ledger carries that mode too. To put an installed package's sample data back to the shipped values, purge it (`…/purge-sample-data`) and then reseed. For a dataset that is reference data the package must keep current, declare `mode: 'upsert'`, and mark the columns the customer or a job owns `applyOn: 'create'`. Leave `mode` unset for business and starter data the customer owns once it is loaded. diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 3cbdac0ba62..769c05d23be 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -25,9 +25,9 @@ import { defineSeed } from '@objectstack/spec/data'; import { Account } from './objects/account.object'; export const accountsSeed = defineSeed(Account, { - externalId: 'name', // field used as the upsert / idempotency key - mode: 'upsert', // create if new, update if found + externalId: 'name', // field that matches a record to an existing row on replay env: ['dev', 'test'], // only load in dev and test environments + // mode omitted → 'ignore': create-only, a replay never overwrites an edit records: [ { name: 'Acme Corporation', @@ -64,41 +64,68 @@ which half TypeScript checks and which half the call checks. The `mode` field controls how the seed runner behaves when it encounters an existing record (matched by `externalId`). +Seeds are **replayed**, not applied once: a seed loads again on every boot of a +single-tenant runtime (production included), on every package install or upgrade, +and on every publish. The mode decides what each of those replays does to a row a +customer has since edited. + | Mode | Behavior | Use Case | |:-----|:---------|:---------| -| `upsert` | Create if new, update if found | Default — idempotent for most data | +| `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 | | `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 | -| `ignore` | Create if new, silently skip duplicates | Bootstrap data that must not overwrite user edits | | `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 | -### `upsert` — Recommended Default +### `ignore` — The Default (Create-Only) ```typescript -defineSeed(Currency, { - externalId: 'code', - mode: 'upsert', +defineSeed(Account, { + externalId: 'name', + // no mode: a replay inserts a missing account and leaves an edited one alone records: [ - { code: 'USD', name: 'US Dollar', symbol: '$' }, - { code: 'EUR', name: 'Euro', symbol: '€' }, - { code: 'GBP', name: 'British Pound', symbol: '£' }, + { name: 'Acme Corporation', industry: 'technology' }, + { name: 'Globex Industries', industry: 'manufacturing' }, ], }); ``` -### `ignore` — Bootstrap Without Overwriting +A seed with no `mode` never writes over a row that already exists. A customer who +renames an account, or a job that advances a status, keeps that change across every +restart and upgrade. The flip side: a value you later change in the seed reaches only +a row the load creates. To see a seed edit in development, start from a clean +database (`os dev --fresh`). + +### `upsert` — Reference Data the Package Keeps Current ```typescript -defineSeed(SystemRole, { +defineSeed(Currency, { externalId: 'code', - mode: 'ignore', + mode: 'upsert', // every replay writes these columns back onto the matched row records: [ - { code: 'admin', label: 'Administrator' }, - { code: 'viewer', label: 'Viewer' }, + { code: 'USD', name: 'US Dollar', symbol: '$' }, + { code: 'EUR', name: 'Euro', symbol: '€' }, + { code: 'GBP', name: 'British Pound', symbol: '£' }, ], }); ``` +Declare `upsert` only for data the package must keep refreshed. Every replay puts +each declared column back, so an edit a customer made to such a row is overwritten +on the next boot or upgrade, silently. To refresh most columns but leave the ones a +customer or a job owns, mark those columns create-only — see +[`applyOn: 'create'`](#columns-your-app-owns-after-the-first-seed--applyon-create). + + + The default used to be `upsert`. A package **built** before the change carries a + written `mode: "upsert"` on every dataset in its compiled artifact (the build stores + the parsed seed), so it keeps replaying with `upsert` until it is rebuilt. A package + installed with `os package install` keeps the body it was installed with, and its + sample-data reseed replays that body, so it also has to be **re-installed** from the + rebuilt artifact. Rebuild (and re-install) to take the create-only default; declare + `mode: 'upsert'` on the datasets that must stay current. + + ### `replace` — Insert Into an Already-Cleared Table ```typescript @@ -123,8 +150,8 @@ Seeds are **replayed** — they re-load on every dev-server boot and every packa re-publish, not applied once. `insert` writes every record with **no existing-row check**, so a replayed `insert` dataset duplicates its table on each restart (a 3-row fixture becomes 6, then 9 — [#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). -It is almost never the right mode: reach for `upsert` (or `ignore`) with a stable -`externalId` instead. `os validate` warns on any `mode: 'insert'` seed. +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. --- @@ -641,11 +668,12 @@ code instead bakes the choice into your build output, so shipping another market means another build. Neither approach removes what the market you left behind already wrote — see [Locale Scoping](#locale-scoping). -### Use `upsert` by default +### Leave `mode` unset for data the customer owns -`upsert` is idempotent and the safest default. Only change the mode when the use -case requires it — for example, `ignore` when you must not overwrite user edits to -system defaults, or `replace` for ephemeral cache tables. +The default, `ignore`, is idempotent and never overwrites an edit. Declare a mode only +when the use case requires it — `upsert` for reference data the package keeps current +(with `applyOn: 'create'` on the columns the customer or a job owns), or `replace` +for ephemeral cache tables. ### Seed in dependency order @@ -673,7 +701,7 @@ function defineSeed< objectDef: TObj, config: { externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name' - mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert' + mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'ignore' columns?: { [field: string]: { applyOn?: 'create' | 'always' } }; // per-column write policy; default: 'always' env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test'] locale?: string[]; // BCP-47 tags; omitted = every locale diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 83216e8716b..88a685f52e6 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -106,7 +106,7 @@ examples/app-todo/ | Concept | File | What You'll Learn | |:--------|:-----|:------------------| | Object & Fields | `task.object.ts` | `Field.text()`, `Field.select()`, field options, indexes | -| Seed Data | `src/data/index.ts` | `defineSeed()` with upsert mode, 8 sample records | +| Seed Data | `src/data/index.ts` | `defineSeed()` with the create-only default, 8 sample records | | Actions | `task.actions.ts` | Script actions, modal actions with params | | App Navigation | `todo.app.ts` | Navigation groups, object links, dashboard links | | Automation | `task.flow.ts` | Autolaunched flow with record-triggered logic | @@ -211,7 +211,7 @@ import { defineSeed } from '@objectstack/spec/data'; import { Task } from '../objects/task.object'; const tasks = defineSeed(Task, { - mode: 'upsert', + // no mode: create-only, so a restart never overwrites a task the user edited externalId: 'subject', records: [ { subject: 'Learn ObjectStack', status: 'completed', priority: 'high' }, diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index a31200a510e..d185002b5e7 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: `"upsert"`) | Conflict resolution strategy | +| **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. | | **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 publish and install paths 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: `"upsert"`) | Conflict resolution strategy | +| **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. | | **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 publish and install paths 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 a217c7cba0e..ff04fc0ead4 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -194,7 +194,7 @@ Seed data loader configuration | **dryRun** | `boolean` | optional (default: `false`) | Validate references without writing data | | **haltOnError** | `boolean` | optional (default: `false`) | Stop on first reference resolution error | | **multiPass** | `boolean` | optional (default: `true`) | Enable multi-pass loading for circular dependencies | -| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Default conflict resolution strategy | +| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only) | | **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert | | **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) | | **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment | @@ -229,7 +229,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: `"upsert"`) | Conflict resolution strategy | +| **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. | | **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 publish and install paths do not filter by locale — they load every dataset and warn | @@ -250,7 +250,7 @@ Seed loader request with datasets and configuration | **dryRun** | `boolean` | optional (default: `false`) | Validate references without writing data | | **haltOnError** | `boolean` | optional (default: `false`) | Stop on first reference resolution error | | **multiPass** | `boolean` | optional (default: `true`) | Enable multi-pass loading for circular dependencies | -| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Default conflict resolution strategy | +| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only) | | **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert | | **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) | | **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment | diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx index 0a92f45a5cd..ad186a9c641 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: `"upsert"`) | Conflict resolution strategy | +| **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. | | **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 publish and install paths 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 225a3b89279..64aba9b6b5d 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: `"upsert"`) | Conflict resolution strategy | +| **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. | | **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 publish and install paths do not filter by locale — they load every dataset and warn | diff --git a/examples/app-crm/src/data/index.ts b/examples/app-crm/src/data/index.ts index 17c60bd4231..c76dc24c34a 100644 --- a/examples/app-crm/src/data/index.ts +++ b/examples/app-crm/src/data/index.ts @@ -8,8 +8,15 @@ import { Opportunity } from '../objects/opportunity.object.js'; import { Lead } from '../objects/lead.object.js'; import { Activity } from '../objects/activity.object.js'; +// Every dataset below is the customer's business data, so none declares a +// `mode`: a replay (each boot, a package install or upgrade) inserts a record +// whose row is missing and leaves an existing row as the customer left it — +// the seed default, create-only. A changed value here therefore reaches only +// a database the seed has not loaded yet; relative dates (`daysFromNow`, +// `daysAgo`) are resolved when the row is first inserted. Start from a clean +// database (`os dev --fresh`) to see an edit here or to re-anchor the dates. + const accounts = defineSeed(Account, { - mode: 'upsert', externalId: 'name', records: [ { name: 'Acme Corp', industry: 'technology', annual_revenue: 5_000_000, website: 'https://acme.example' }, @@ -19,7 +26,6 @@ const accounts = defineSeed(Account, { }); const contacts = defineSeed(Contact, { - mode: 'upsert', externalId: 'email', records: [ { first_name: 'Ada', last_name: 'Lovelace', email: 'ada@acme.example', account: 'Acme Corp' }, @@ -29,7 +35,6 @@ const contacts = defineSeed(Contact, { }); const opportunities = defineSeed(Opportunity, { - mode: 'upsert', externalId: 'name', records: [ // --- Open pipeline (no close yet) ----------------------------------- @@ -57,7 +62,6 @@ const opportunities = defineSeed(Opportunity, { }); const leads = defineSeed(Lead, { - mode: 'upsert', externalId: 'email', // No `assigned_to` on these rows. It is a user field, and no user exists // when the seed runs: people come from sign-up, and the dev admin is @@ -101,7 +105,6 @@ const leads = defineSeed(Lead, { }); const activities = defineSeed(Activity, { - mode: 'upsert', externalId: 'subject', records: [ { diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 52f98b391cf..d17e177af78 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -88,8 +88,12 @@ const ADA = AUDITOR_DEMO_USER.email; * information-dense empty state rather than broken images. */ +// No dataset in this file declares `mode: 'upsert'`: each is the demo +// workspace's business data, which a user edits once it is seeded, so every +// replay is create-only (the seed default) — a missing row is inserted and an +// existing row is left as the user left it. A changed value here reaches only +// a database the seed has not loaded yet (`os dev --fresh`). const accounts = defineSeed(Account, { - mode: 'upsert', externalId: 'name', records: [ // `status` is required and no default is applied at seed-insert time, so it @@ -126,7 +130,6 @@ const accounts = defineSeed(Account, { // (cascading) lookup demo: picking an Account on an invoice scopes its // Contact picker to that account's people (invoice.contact dependsOn account). const contacts = defineSeed(Contact, { - mode: 'upsert', externalId: 'email', records: [ { name: 'Nora West', email: 'nora@northwind.example', phone: '+1 555 010 1111', company: 'Northwind', title: 'Procurement Lead', account: 'Northwind', stage: 'qualified' }, @@ -166,11 +169,12 @@ const contacts = defineSeed(Contact, { * from the `state_machine` rule (#3433). That lets each project be seeded * DIRECTLY at its real status; no FSM-walk workaround (this used to seed all * five as `planned` then upsert-hop them through legal transitions, #3415). - * `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so - * dev-server restarts and package re-publishes never churn or re-validate. + * The dataset declares no `mode`, so a replay is create-only (the seed + * default): dev-server restarts and package re-publishes insert a missing + * project and leave an existing one — and any edit made to it — alone, so + * nothing churns or re-validates. */ const projects = defineSeed(Project, { - mode: 'upsert', externalId: 'name', records: [ { name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: ADA, start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` }, @@ -183,7 +187,6 @@ const projects = defineSeed(Project, { // Tasks across all five board columns, with dates + locations to drive every view. const tasks = defineSeed(Task, { - mode: 'upsert', externalId: 'title', records: [ { title: 'Audit current IA', project: 'Website Relaunch', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, @@ -200,7 +203,6 @@ const tasks = defineSeed(Task, { }); const categories = defineSeed(Category, { - mode: 'upsert', externalId: 'name', records: [ { name: 'Engineering', sort_order: 1, color: '#3B82F6' }, @@ -213,7 +215,6 @@ const categories = defineSeed(Category, { // Org-chart hierarchy seeded by `name` external id; `parent` references another // record's name, building a 3-level tree the `tree` view renders. const businessUnits = defineSeed(BusinessUnit, { - mode: 'upsert', externalId: 'name', records: [ { name: 'Acme Corporation', kind: 'company', manager: 'Dana Wong', headcount: 0 }, @@ -248,7 +249,6 @@ const businessUnits = defineSeed(BusinessUnit, { */ const orgUnits = SeedSchema.parse({ object: 'sys_business_unit', - mode: 'upsert', externalId: 'id', records: [ { id: 'bu_acme', name: 'Acme Corporation', code: 'ACME', kind: 'company', active: true }, @@ -265,7 +265,6 @@ const orgUnits = SeedSchema.parse({ // from the definition — no binding row exists to seed. const teams = defineSeed(Team, { - mode: 'upsert', externalId: 'name', records: [ { name: 'Platform', lead: 'linus@example.com', capacity_hours: 200 }, @@ -276,7 +275,6 @@ const teams = defineSeed(Team, { // Catalog products the invoice line's `product` lookup picks from. Selecting // one auto-fills the line's description + unit_price (matching field names). const products = defineSeed(Product, { - mode: 'upsert', externalId: 'sku', records: [ { sku: 'WIDGET-A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, active: true }, @@ -319,7 +317,6 @@ const memberships = defineSeed(ProjectMembership, { // The multi-value USER field (`f_users`) stays unseeded on purpose: sys_user // rows come from sign-up, not seeds — see the note on the object. const fieldZoo = defineSeed(FieldZoo, { - mode: 'upsert', externalId: 'name', records: [ { @@ -358,7 +355,6 @@ const fieldZoo = defineSeed(FieldZoo, { // `showcase_invoice_line` is `controlled_by_parent`, the lines below follow // automatically — ada sees INV-1001/1002's lines but never linus's INV-1003. const invoices = defineSeed(Invoice, { - mode: 'upsert', externalId: 'name', records: [ { name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8, region: 'amer' }, @@ -394,7 +390,6 @@ const invoices = defineSeed(Invoice, { // start clock; the goods lines are empty on purpose, which is the fixture's // empty-cell case. const invoiceLines = defineSeed(InvoiceLine, { - mode: 'upsert', externalId: 'description', records: [ { description: 'INV-1001 \u00b7 Consulting hours', invoice: 'INV-1001', product: 'SERVICE-HR', position: 0, quantity: 10, unit_price: 150, amount: 1500, service_start: '09:15' }, @@ -412,7 +407,6 @@ const invoiceLines = defineSeed(InvoiceLine, { // story in the app by flipping a line's status in the inline grid and watching // approved_amount / rejected_count move. Merchants are unique → externalId. const expenseReports = defineSeed(ExpenseReport, { - mode: 'upsert', externalId: 'name', records: [ { name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` }, @@ -436,7 +430,6 @@ const expenseReports = defineSeed(ExpenseReport, { * documents for exactly this — ` + duration("Nh…")`. */ const expenseLines = defineSeed(ExpenseLine, { - mode: 'upsert', externalId: 'merchant', records: [ // EXP-2001 → total 1500.50 · approved 960 · reimbursable 512 · rejected 0 · over$500 2 @@ -463,7 +456,6 @@ const expenseLines = defineSeed(ExpenseLine, { // renders on first boot — the "every view renders real data" principle. One // per status; the `closed` row doubles as live prey for InquiryPurgeFlow. const inquiries = defineSeed(Inquiry, { - mode: 'upsert', externalId: 'email', records: [ { name: 'Priya Raman', email: 'priya@meridian.example', company: 'Meridian Labs', message: 'Interested in the delivery workspace for a 40-person team.', status: 'new', source: 'website' }, @@ -473,7 +465,6 @@ const inquiries = defineSeed(Inquiry, { }); const preferences = defineSeed(Preference, { - mode: 'upsert', externalId: 'name', records: [ { name: 'My Preferences', theme: 'light', default_landing: 'my_work', email_digest: 'daily', items_per_page: 50, notifications_enabled: true, compact_density: false }, @@ -488,7 +479,6 @@ const preferences = defineSeed(Preference, { * (only `showcase_ops` may create; everyone else is read-only by design). */ const announcements = defineSeed(Announcement, { - mode: 'upsert', externalId: 'title', records: [ { title: 'Welcome to the Showcase workspace', body: 'This demo org exercises the full permission model: positions, permission sets, sharing rules and field-level security. Log in as different personas to compare what each can see and edit.' }, @@ -512,7 +502,6 @@ const announcements = defineSeed(Announcement, { * proves nothing. */ const clientBriefs = defineSeed(ClientBrief, { - mode: 'upsert', externalId: 'title', records: [ { diff --git a/examples/app-todo/src/data/index.ts b/examples/app-todo/src/data/index.ts index ac2b4aa2a85..d9af204dbf0 100644 --- a/examples/app-todo/src/data/index.ts +++ b/examples/app-todo/src/data/index.ts @@ -4,8 +4,11 @@ import { defineSeed } from '@objectstack/spec/data'; import { cel } from '@objectstack/spec'; import { Task } from '../objects/task.object'; +// The tasks are the user's own once seeded, so the dataset declares no +// `mode`: a replay inserts a missing task and leaves an existing one as the +// user left it (the seed default, create-only). Relative dates are resolved +// when the row is first inserted; `os dev --fresh` starts from a clean database. const tasks = defineSeed(Task, { - mode: 'upsert', externalId: 'subject', // NOTE: `owner` is a lookup to `sys_user`; there's no stable seed key for the // bootstrap admin (created by auth), so it's left unset rather than pointing diff --git a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts index d6fd42e83c0..0f0bde4714b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts @@ -180,9 +180,12 @@ describe('rehydrate sample-data healing', () => { await rehydrateWith({}, { crm_x: [], crm_y: [] }); expect(loadCalls).toHaveLength(1); - // The full bundled datasets are replayed, as an upsert. + // The full bundled datasets are replayed, each in its own mode: the + // healer sets no mode of its own, so a dataset that declares none + // takes the spec default, create-only (#30081). expect(loadCalls[0].seeds).toHaveLength(2); - expect(loadCalls[0].config).toMatchObject({ defaultMode: 'upsert', multiPass: true }); + expect(loadCalls[0].config).toMatchObject({ multiPass: true }); + expect(loadCalls[0].config).not.toHaveProperty('defaultMode'); // No org id in single-tenant mode. expect(loadCalls[0].config.organizationId).toBeUndefined(); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ec8cba1e9c5..61e7c9fb877 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -3783,11 +3783,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { /** * POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data * - * Re-runs SeedLoaderService against the cached manifest's `data` arrays. - * Idempotent (upsert by id). Useful when: + * Re-runs SeedLoaderService against the cached manifest's `data` arrays, + * each dataset in the mode the installed body carries ([#30081] the spec + * default `ignore`, create-only, when it declares none). Idempotent. + * Useful when: * • The user installed an app and skipped sample data - * • A purge was undone - * • The user wants a clean baseline back after editing demo rows + * • A purge was undone, or demo rows were deleted + * It puts back the rows that are missing and leaves an edited row as it + * is; to get the shipped values back over edits, purge first, then + * reseed. A ledger entry installed from an artifact built before #30081 + * carries a written `mode: "upsert"` and still writes the shipped values + * back on every reseed until the rebuilt artifact is re-installed. * * A success answers the loader's four counts — `inserted`, `updated`, * `skipped`, `errors` — so the caller sees why nothing changed. A run over @@ -3927,7 +3933,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * {@link purgeSeedRows} carries the rules; this handler supplies the scope. * * • A seed row is matched by the seed's own key — each dataset's - * `externalId`, the key the install's upsert and the reseed match on. + * `externalId`, the key the install and the reseed match on. * A row whose key no seed record declares (user-authored data) is never * touched; a key carried by more than one row in scope is not guessed. * • Scope is the install's: under an organization wall, the caller's @@ -4618,7 +4624,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; /** - * One SeedLoaderService run over `datasets` (upsert, multi-pass) — the + * One SeedLoaderService run over `datasets` (multi-pass, each dataset in + * its own `mode`, the spec default `ignore` when it declares none) — the * shared engine behind install-time seeding, the reseed endpoint and the * rehydrate-time healer. Throws when objectql/metadata are unavailable; * per-row write failures are counted into `errors`, not thrown. @@ -4642,7 +4649,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // ADR-0036 / seed rename: the field is `seeds` (was `datasets`). seeds: datasets, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own: an install, an upgrade or a + // reseed inserts the rows a dataset is missing and leaves the + // customer's existing rows alone, unless the dataset declares + // `mode: 'upsert'` (the spec default, ruling 6631016988). multiPass: true, ...(organizationId ? { organizationId } : {}), }, diff --git a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts index 5adb185ed04..07c8bc2319b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts @@ -28,10 +28,12 @@ import { tmpdir } from 'node:os'; // Controls what the (mocked) seed loader reports back. The handler under test // only cares about result.summary.total{Inserted,Updated,Skipped} + result.errors. let seedResult: any = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; +// Every request the (mocked) loader was handed, in order. +let loadCalls: any[] = []; vi.mock('@objectstack/runtime', () => ({ SeedLoaderService: class { - async load() { return seedResult; } + async load(request: any) { loadCalls.push(request); return seedResult; } }, })); // Only the seed-request parse is stubbed; every other export stays the real one, @@ -108,6 +110,7 @@ let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-reseed-')); seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + loadCalls = []; }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -232,3 +235,39 @@ describe('reseed honest result', () => { expect(res.payload?.data).toMatchObject({ inserted: 1, skipped: 1, errors: 1 }); }); }); + +/** + * The reseed follows the create-only seed default (#30081). The handler sets + * no mode of its own, so the request it builds — parsed here by the REAL + * `SeedLoaderRequestSchema`, which the suite stubs to identity above — hands + * the loader a mode-less dataset as `ignore`: a reseed inserts the rows that + * are missing and leaves a row the customer kept, or edited, as it is. A + * dataset that declares `mode: 'upsert'` keeps it. + */ +describe('reseed follows the create-only seed default', () => { + it('sets no mode of its own; a mode-less dataset parses to ignore, a declared upsert stays upsert', async () => { + const { SeedLoaderRequestSchema: RealSchema } = + await vi.importActual('@objectstack/spec/data'); + const rawApp = await installAndGetRoutes(); + const declared = { object: 'pm_x', mode: 'upsert', records: [{ name: 'ref' }] }; + const installRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!( + makeC({ manifest: { ...MANIFEST, id: 'app.test.declared', data: [declared] } }), + ); + expect(installRes.payload?.success).toBe(true); + loadCalls = []; + seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + + await rawApp.routes.get(RESEED)!(makeC({}, 'app.test.proj')); + await rawApp.routes.get(RESEED)!(makeC({}, 'app.test.declared')); + expect(loadCalls).toHaveLength(2); + + for (const call of loadCalls) { + expect(call.config).toMatchObject({ multiPass: true }); + expect(call.config).not.toHaveProperty('defaultMode'); + } + const silent = RealSchema.parse(loadCalls[0]); + expect(silent.seeds.map((d) => d.mode)).toEqual(['ignore']); + expect(silent.config.defaultMode).toBe('ignore'); + expect(RealSchema.parse(loadCalls[1]).seeds.map((d) => d.mode)).toEqual(['upsert']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol-publish-batch.ts b/packages/metadata-protocol/src/protocol-publish-batch.ts index 838c1b9a1d2..0b43a2f1c55 100644 --- a/packages/metadata-protocol/src/protocol-publish-batch.ts +++ b/packages/metadata-protocol/src/protocol-publish-batch.ts @@ -112,7 +112,9 @@ export async function applySeedBodies( const parsedRequest = SeedLoaderRequestSchema.safeParse({ seeds, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own: a published seed's `mode` is + // the spec default (`ignore`, create-only) unless it declares + // one, so a re-publish never writes over a customer's edit. multiPass: true, // [ADR-0131 D6, §12] No caller organization: a seed dataset // names the organization it populates itself, and the diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts index edf2c3fe986..89d62d1e99d 100644 --- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts +++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts @@ -62,7 +62,7 @@ import { import { resolveItemLock, withItemLockFamily } from './item-lock.js'; import type { ItemAddress } from './item-lock.js'; import { itemDiscriminator } from '@objectstack/metadata-core'; -import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns, SeedSchema } from '@objectstack/spec/data'; import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; @@ -879,10 +879,11 @@ export async function seedExternalIdSaveRefusal( const seed = item as { object?: unknown; mode?: unknown; externalId?: unknown }; if (typeof seed.object !== 'string' || seed.object === '') return undefined; const objectName = seed.object; - // `SeedSchema`'s defaults, as the loader applies them (`mode: 'upsert'`, + // `SeedSchema`'s defaults, as the loader applies them (the `mode` default, + // read from the schema rather than restated here [#30081], and // `externalId: 'name'`): a body read before the parse filled them keys the // same way a parsed one does. - const mode = typeof seed.mode === 'string' ? seed.mode : 'upsert'; + const mode = typeof seed.mode === 'string' ? seed.mode : SeedSchema.shape.mode.parse(undefined); if (!KEYED_SEED_MODES.has(mode)) return undefined; const declared = seed.externalId !== undefined; const externalId = declared ? seed.externalId : 'name'; diff --git a/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts index 26bea21141b..840af8737f7 100644 --- a/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts +++ b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts @@ -105,7 +105,9 @@ function createMetadata(): IMetadataService { } as unknown as IMetadataService; } -// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on). +// An upsert config, multi-pass on: how a dataset that declares no mode replays +// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset +// carries its own, `ignore` when it declares none.) const CONFIG = { dryRun: false, haltOnError: false, diff --git a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts index aa11de17476..6e737eed455 100644 --- a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts +++ b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts @@ -124,7 +124,9 @@ function createMetadata(): IMetadataService { } as unknown as IMetadataService; } -// The AppPlugin inline-seed config: upsert, multi-pass. +// An upsert config, multi-pass on: how a dataset that declares no mode replays +// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset +// carries its own, `ignore` when it declares none.) const CONFIG = { dryRun: false, haltOnError: false, diff --git a/packages/metadata-protocol/src/seed-loader-replay.test.ts b/packages/metadata-protocol/src/seed-loader-replay.test.ts index b4fcdc3e02c..33cc2fe41d9 100644 --- a/packages/metadata-protocol/src/seed-loader-replay.test.ts +++ b/packages/metadata-protocol/src/seed-loader-replay.test.ts @@ -108,7 +108,9 @@ function createMetadata(): IMetadataService { } as unknown as IMetadataService; } -// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on). +// An upsert config, multi-pass on: how a dataset that declares no mode replays +// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset +// carries its own, `ignore` when it declares none.) const CONFIG = { dryRun: false, haltOnError: false, diff --git a/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts index eb442fae22b..d81c0206e8c 100644 --- a/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts +++ b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts @@ -142,7 +142,9 @@ function createMetadata(): IMetadataService { } as unknown as IMetadataService; } -// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on). +// An upsert config, multi-pass on: how a dataset that declares no mode replays +// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset +// carries its own, `ignore` when it declares none.) const CONFIG = { dryRun: false, haltOnError: false, diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 94326730178..6af5ac58707 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1814,7 +1814,9 @@ export class AppPlugin implements Plugin { const request = SeedLoaderRequestSchema.parse({ seeds: datasetsNow, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own: each dataset's + // `mode` is the spec default (`ignore`, + // create-only) unless the dataset declares one. multiPass: true, organizationId, // `os.org` is derived from organizationId inside @@ -1916,7 +1918,11 @@ export class AppPlugin implements Plugin { const request = SeedLoaderRequestSchema.parse({ seeds: normalizedDatasets, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own: a replay on a + // later boot inserts a missing row and leaves an + // existing one as the customer left it, unless the + // dataset declares `mode: 'upsert'` (the spec + // default, `SeedSchema.mode`, ruling 6631016988). multiPass: true, identity: seedIdentity, ...(seedLocale ? { locale: seedLocale } : {}), @@ -2340,7 +2346,7 @@ export class AppPlugin implements Plugin { const request = SeedLoaderRequestSchema.parse({ seeds, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own — the spec default applies. multiPass: true, ...(seedLocale ? { locale: seedLocale } : {}), }, diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 0366853f482..24765d51f2f 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3964,7 +3964,8 @@ _context: HttpProtocolContext, const parsedRequest = SeedLoaderRequestSchema.safeParse({ seeds: datasets, config: { - defaultMode: 'upsert', + // [#30081] No mode of its own: each seed's `mode` is the spec + // default (`ignore`, create-only) unless the seed declares one. multiPass: true, }, }); diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts index c10aec703bb..937db858545 100644 --- a/packages/runtime/src/seed-datasets.test.ts +++ b/packages/runtime/src/seed-datasets.test.ts @@ -217,12 +217,14 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () => 'crm_contact', 'hot_lead', ]); - // Tenant-scoped, upsert, multi-pass — as the org replay requires. + // Tenant-scoped, multi-pass — as the org replay requires. No mode of + // its own: each dataset replays in the mode it declares, the spec + // default (create-only) when it declares none (#30081). expect(loadCalls[0].config).toMatchObject({ - defaultMode: 'upsert', multiPass: true, organizationId: 'org_fresh', }); + expect(loadCalls[0].config).not.toHaveProperty('defaultMode'); expect(result).toMatchObject({ inserted: 3 }); }); @@ -245,3 +247,51 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () => expect(loadCalls.at(-1).seeds.map((s: any) => s.object)).toEqual(['crm_account', 'hot_lead']); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +/** + * #30081 — one seed default, stated once. The inline seed a single-tenant boot + * runs builds its request with no mode of its own: each dataset replays in the + * mode it declares, the spec default (`ignore`, create-only) when it declares + * none. The per-organization replayer above and the install-local doors + * (cloud-connection) are pinned the same way. + */ +describe('AppPlugin inline boot seed sets no mode of its own (#30081)', () => { + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + + beforeEach(() => { + loadCalls = []; + seedResult = loaderResult({ totalInserted: 1, totalUpdated: 0, totalSkipped: 0 }); + delete process.env.OS_MULTI_ORG_ENABLED; // single-tenant: the inline seed runs at boot + }); + afterEach(() => { + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + vi.restoreAllMocks(); + }); + + it('hands the loader a multi-pass request with no defaultMode', async () => { + const services = new Map(); + services.set('objectql', { insert: vi.fn(async () => undefined) }); + services.set('metadata', {}); // present → the SeedLoaderService path, not the basic-insert fallback + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getService: (n: string) => (services.has(n) ? services.get(n) : undefined), + registerService: (n: string, v: any) => { + if (services.has(n)) throw new Error(`[Kernel] Service '${n}' already registered`); + services.set(n, v); + }, + getServices: () => new Map(services), + hook: vi.fn(), + trigger: vi.fn(), + }; + + await new AppPlugin(appBundle('config-app-boot', ds('crm_account', 'a1'))).start(ctx); + + await vi.waitFor(() => expect(loadCalls).toHaveLength(1)); + expect(loadCalls[0].seeds.map((s: any) => s.object)).toEqual(['crm_account']); + expect(loadCalls[0].config).toMatchObject({ multiPass: true }); + expect(loadCalls[0].config).not.toHaveProperty('defaultMode'); + expect(loadCalls[0].config).not.toHaveProperty('organizationId'); + }); +}); diff --git a/packages/runtime/src/seed-default-create-only-replay.integration.test.ts b/packages/runtime/src/seed-default-create-only-replay.integration.test.ts new file mode 100644 index 00000000000..9fac4a5dc73 --- /dev/null +++ b/packages/runtime/src/seed-default-create-only-replay.integration.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A seed with no `mode` is create-only (#30081) — measured on a REAL + * `SqlDriver` under a real `ObjectQL`. + * + * A seed is replayed on every single-tenant boot, on every package install or + * upgrade and on every publish. With the old `upsert` default each replay + * wrote the shipped value back over a customer's edit to a seeded row, in + * production too and with no signal (measured on the card before the change, + * ruling 6631016988, option A). The default is now `ignore`. + * + * The request is built the way every replay site builds it after the change: + * through `SeedLoaderRequestSchema`, with no `defaultMode` of its own. Pinned, + * for a dataset that declares no mode, across a replay of a NEW seed version: + * + * 1. A customer's edit to a seeded row survives the replay. + * 2. A record the new version adds, missing from the table, is inserted. + * 3. A row the customer never touched is left as it is — the new version's + * value does not reach it and its `updated_at` does not move. + * + * Control, same steps with `mode: 'upsert'` declared: the shipped value is + * written back over the customer's edit and onto the untouched row, whose + * `updated_at` moves — so (1) and (3) above are the default's doing, not a + * loader that writes nothing. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SeedLoaderService } from '@objectstack/metadata-protocol'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SeedLoaderRequestSchema } from '@objectstack/spec/data'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; + +const ROW = { + name: 'seed_owned_row', + fields: { + name: { type: 'text', required: true }, + stage: { type: 'text' }, + amount: { type: 'number' }, + }, +}; +const OBJECTS = [ROW]; +const SYSTEM = { context: { isSystem: true } } as any; +const logger = { info() {}, warn() {}, error() {}, debug() {} }; + +/** The package's first version, then the upgrade it ships later. */ +const V1 = [ + { name: 'edited', stage: 'new', amount: 1 }, + { name: 'untouched', stage: 'new', amount: 2 }, +]; +const V2 = [ + { name: 'edited', stage: 'shipped-v2', amount: 1 }, + { name: 'untouched', stage: 'new', amount: 20 }, + { name: 'added', stage: 'new', amount: 3 }, +]; + +/** The request every replay site builds: parsed, with no mode of its own. */ +function request(records: unknown[], mode?: 'upsert') { + return SeedLoaderRequestSchema.parse({ + seeds: [{ object: 'seed_owned_row', records, ...(mode ? { mode } : {}) }], + config: { multiPass: true }, + }); +} + +function metadataFor(objects: any[]) { + const byName = new Map(objects.map((o) => [o.name, o])); + return { + getObject: async (name: string) => byName.get(name), + listObjects: async () => objects, + register: async () => {}, get: async (_t: string, n: string) => byName.get(n), + list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [], + } as any; +} + +describe('the replay request every site builds (#30081)', () => { + it('carries the create-only default and no mode of the caller\'s own', () => { + const parsed = request(V1); + expect(parsed.seeds[0].mode).toBe('ignore'); + expect(parsed.config.defaultMode).toBe('ignore'); + expect(request(V1, 'upsert').seeds[0].mode).toBe('upsert'); + }); +}); + +describe('a seed with no mode is create-only on replay, on a REAL SqlDriver (#30081)', () => { + let dir: string | null = null; + let engine: ObjectQL | null = null; + let noise: ExpectedReadRefusalCapture | null = null; + + afterEach(async () => { + vi.useRealTimers(); + try { await engine?.destroy(); } catch { /* noop */ } + engine = null; + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); + noise = null; + }); + + async function boot() { + // Only `Date` is faked, so each step stamps a distinct `updated_at`. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-11-04T10:00:00.000Z')); + dir = mkdtempSync(join(tmpdir(), 'os-seed-create-only-')); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + noise = captureExpectedReadRefusals(['sys_organization']); + noise.captureDriver(driver); + await driver.initObjects(OBJECTS); + engine = new ObjectQL(); + noise.captureEngine(engine); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of OBJECTS) engine.registry.registerObject(o as any); + const knex = (driver as any).knex; + const stored = async (name: string) => + (await knex('seed_owned_row').where({ name }).first()) as Record | undefined; + const loader = new SeedLoaderService(engine as any, metadataFor(OBJECTS), logger); + + /** First load, then a customer edit, then the upgrade's replay. */ + async function installEditUpgrade(mode?: 'upsert') { + const first = await loader.load(request(V1, mode)); + expect(first.errors.map((x: any) => x.message)).toEqual([]); + expect(first.summary.totalInserted).toBe(2); + const untouchedBefore = await stored('untouched'); + + vi.setSystemTime(new Date('2026-11-04T11:00:00.000Z')); + const edited = await stored('edited'); + await engine!.update('seed_owned_row', { id: edited!.id, stage: 'customer-edit' }, SYSTEM); + expect((await stored('edited'))!.stage).toBe('customer-edit'); + + vi.setSystemTime(new Date('2026-11-04T12:00:00.000Z')); + const replay = await loader.load(request(V2, mode)); + expect(replay.errors.map((x: any) => x.message)).toEqual([]); + return { replay, untouchedBefore: untouchedBefore! }; + } + + return { stored, installEditUpgrade }; + } + + it('a customer edit survives, a missing row is inserted, an untouched row and its updated_at stay put', async () => { + const { stored, installEditUpgrade } = await boot(); + const { replay, untouchedBefore } = await installEditUpgrade(); + + expect(replay.summary.totalInserted).toBe(1); + expect(replay.summary.totalUpdated).toBe(0); + expect(replay.summary.totalSkipped).toBe(2); + + // (1) the customer's edit is still there + expect((await stored('edited'))!.stage).toBe('customer-edit'); + // (2) the record the upgrade added is inserted + expect(await stored('added')).toMatchObject({ stage: 'new', amount: 3 }); + // (3) the untouched row keeps its first value and its updated_at + const untouchedAfter = (await stored('untouched'))!; + expect(untouchedBefore.updated_at).toBeTruthy(); + expect(untouchedAfter.amount).toBe(2); + expect(untouchedAfter.updated_at).toEqual(untouchedBefore.updated_at); + }); + + it('control: an explicit upsert still writes the shipped value back on replay', async () => { + const { stored, installEditUpgrade } = await boot(); + const { replay, untouchedBefore } = await installEditUpgrade('upsert'); + + expect(replay.summary.totalInserted).toBe(1); + expect(replay.summary.totalUpdated).toBe(2); + + expect((await stored('edited'))!.stage).toBe('shipped-v2'); + expect(await stored('added')).toMatchObject({ stage: 'new', amount: 3 }); + const untouchedAfter = (await stored('untouched'))!; + expect(untouchedAfter.amount).toBe(20); + expect(untouchedAfter.updated_at).not.toEqual(untouchedBefore.updated_at); + }); +}); diff --git a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts index 734ac0020ba..a647ab1da7f 100644 --- a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts @@ -147,7 +147,7 @@ async function plantOwn(plant: (o: string, r: Record) => Promis function scopedRequest(organizationId: string, seeds: unknown = SEEDS) { return SeedLoaderRequestSchema.parse({ seeds, - config: { defaultMode: 'upsert', multiPass: true, organizationId }, + config: { multiPass: true, organizationId }, }); } @@ -246,8 +246,8 @@ describe('every scoped caller reads the same way (sqlite-file)', () => { }); // `MarketplaceInstallLocalPlugin.runInlineSeed` serves both doors and builds - // the same request for each: `defaultMode: 'upsert'`, `multiPass: true`, the - // caller's organization. + // the same request for each: `multiPass: true` and the caller's organization, + // each dataset in its own mode. it('walled install and reseed: the request runInlineSeed builds for both', async () => { const { engine, all, plant } = await boot(); await plantOwn(plant); diff --git a/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts index 8e425f7edbc..04807e20c05 100644 --- a/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts +++ b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts @@ -126,7 +126,7 @@ function createLogger() { /** * Boot the walled composition and hand back the replayer AppPlugin registered. * The replayer is the production closure: it builds the per-organization - * request (`defaultMode: 'upsert'`, `multiPass: true`, `organizationId`) and + * request (`multiPass: true`, `organizationId`, each dataset in its own mode) and * runs the real loader against the real engine. */ async function bootWalled(dataset: typeof ORG_UNITS) { diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index b15afc4aa00..9175f9200e6 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -97,19 +97,19 @@ "data/ScriptValidation:severity = \"error\"", "data/Seed:env = [\"prod\",\"dev\",\"test\"]", "data/Seed:externalId = \"name\"", - "data/Seed:mode = \"upsert\"", + "data/Seed:mode = \"ignore\"", "data/SeedColumn:applyOn = \"always\"", "data/SeedLoadResult:errors = []", "data/SeedLoadResult:landedIds = []", "data/SeedLoadResult:referencesDropped = 0", "data/SeedLoadResult:summariesStale = 0", "data/SeedLoaderConfig:batchSize = 1000", - "data/SeedLoaderConfig:defaultMode = \"upsert\"", + "data/SeedLoaderConfig:defaultMode = \"ignore\"", "data/SeedLoaderConfig:dryRun = false", "data/SeedLoaderConfig:haltOnError = false", "data/SeedLoaderConfig:multiPass = true", "data/SeedLoaderConfig:transaction = false", - "data/SeedLoaderRequest:config = {\"batchSize\":1000,\"defaultMode\":\"upsert\",\"dryRun\":false,\"haltOnError\":false,\"multiPass\":true,\"transaction\":false}", + "data/SeedLoaderRequest:config = {\"batchSize\":1000,\"defaultMode\":\"ignore\",\"dryRun\":false,\"haltOnError\":false,\"multiPass\":true,\"transaction\":false}", "data/ShardingConfig:enabled = false", "data/SortNode:order = \"asc\"", "data/SqliteConfig:filename = \":memory:\"", diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index ef686a91d61..4aa649575c2 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -58,6 +58,35 @@ import type { DeclaredDefaultChange } from './authorable-defaults.js'; +/** + * Shared by the three seed-mode rows below (#30081): the dataset default, the + * loader configuration's fallback that now IS that default, and the request's + * parsed default `config` that carries it. + */ +const SEED_MODE_CREATE_ONLY_REASON = + 'A RULED default change (ruling 6631016988 on #30081, option A, after a production measurement): a ' + + 'seed is create-only by default. A load inserts the records whose row is missing and leaves every ' + + 'existing row as it is, so a replay — every single-tenant boot, a package install or upgrade, a ' + + 'publish of a `seed` item — no longer writes a seeded value back over a customer\'s edit. Measured ' + + 'before the change on `os start`, `os serve`, a boot-artifact upgrade and an `os package install` ' + + 'upgrade: each put an edited seeded row back to the shipped value, silently. What moves for a ' + + 'consumer: a dataset that omits `mode` parses to `ignore` where it parsed to `upsert`, so a replay ' + + 'no longer refreshes rows that already exist — a changed seed value reaches only a row the load ' + + 'creates. To keep refreshing a dataset (reference data the package keeps current), write ' + + '`mode: \'upsert\'`, and exempt the columns the customer or a job owns with ' + + '`columns: { FIELD: { applyOn: \'create\' } }`. A package BUILT before this change carries a written `mode: "upsert"` in its compiled ' + + 'artifact (`os build` stores the parsed seed), which the loader cannot tell from an authored one, ' + + 'so it keeps replaying with upsert until it is rebuilt; nothing converts it. An install-local ' + + 'package (`os package install`) keeps the body it was installed with in its install ledger, and its ' + + 'sample-data reseed replays that body in its stored mode, so an entry installed from such an ' + + 'artifact keeps writing the shipped values back on every reseed until the rebuilt artifact is ' + + 're-installed: a rebuild alone changes nothing for it. The ledger\'s other replays never overwrite: ' + + 'the boot rehydrate seeds nothing, the heal seeds only when every seeded object is empty, and the ' + + 'per-organization replay runs only for a new organization. A stored `seed` metadata item with no ' + + '`mode` is not rewritten either: its next publish parses it to `ignore`, so that publish inserts ' + + 'only the rows it is missing where it used to write its values back over existing rows, with no ' + + 'signal. D3 entry: `seed-mode-default-create-only`.'; + /** * Declared default changes, keyed by the protocol major that shipped them. * @@ -742,5 +771,31 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly { expect(config.dryRun).toBe(false); expect(config.haltOnError).toBe(false); expect(config.multiPass).toBe(true); - expect(config.defaultMode).toBe('upsert'); + expect(config.defaultMode).toBe('ignore'); expect(config.batchSize).toBe(1000); expect(config.transaction).toBe(false); expect(config.env).toBeUndefined(); }); + it('takes its defaultMode from the seed mode default — one default, stated once (#30081)', () => { + const seedDefault = SeedSchema.parse({ object: 'product', records: [] }).mode; + expect(SeedLoaderConfigSchema.parse({}).defaultMode).toBe(seedDefault); + }); + it('should accept dry-run configuration', () => { const config = SeedLoaderConfigSchema.parse({ dryRun: true, @@ -582,7 +588,10 @@ describe('SeedLoaderRequestSchema', () => { }); expect(request.seeds).toHaveLength(1); expect(request.config.dryRun).toBe(false); - expect(request.config.defaultMode).toBe('upsert'); + expect(request.config.defaultMode).toBe('ignore'); + // Each dataset is parsed through SeedSchema, so a mode-less dataset + // reaches the loader already carrying the create-only default. + expect(request.seeds[0].mode).toBe('ignore'); }); it('should accept a request with full configuration', () => { diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index d0b0b482b81..3f77d785300 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -240,10 +240,18 @@ export const SeedLoaderConfigSchema = lazySchema(() => z.object({ /** * Default dataset mode when not specified per-dataset. - * @default 'upsert' + * + * It is {@link SeedSchema}'s own `mode` field, default included, so the + * seed default is stated once: `ignore` (create-only, ruling 6631016988 on + * objectstack#30081). A request parsed through + * {@link SeedLoaderRequestSchema} fills every dataset's `mode` from that + * same default, so this value is read only for a dataset that reaches the + * loader unparsed (`validate()`). A caller ⛔ never restates a mode of its + * own here. + * @default 'ignore' */ - defaultMode: SeedMode.default('upsert') - .describe('Default conflict resolution strategy'), + defaultMode: SeedSchema.shape.mode + .describe('Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only)'), /** * Maximum number of records to process in a single batch. diff --git a/packages/spec/src/data/seed.test.ts b/packages/spec/src/data/seed.test.ts index bbc8885e18e..64b890581b1 100644 --- a/packages/spec/src/data/seed.test.ts +++ b/packages/spec/src/data/seed.test.ts @@ -54,10 +54,18 @@ describe('SeedSchema', () => { }); expect(dataset.externalId).toBe('name'); - expect(dataset.mode).toBe('upsert'); + // Create-only by default (ruling 6631016988 on #30081): a replay inserts + // the missing rows and leaves an existing row as the customer left it. + expect(dataset.mode).toBe('ignore'); expect(dataset.env).toEqual(['prod', 'dev', 'test']); }); + it('keeps every authored mode as written — only an omitted mode takes the default', () => { + for (const mode of ['insert', 'update', 'upsert', 'replace', 'ignore'] as const) { + expect(SeedSchema.parse({ object: 'product', mode, records: [] }).mode).toBe(mode); + } + }); + it('should validate object name format (snake_case)', () => { expect(() => SeedSchema.parse({ object: 'valid_object_name', diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 404d450969f..dc7831d364a 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -23,9 +23,9 @@ import { export const SeedMode = z.enum([ 'insert', // Try to insert, fail on duplicate 'update', // Only update found records, ignore new - 'upsert', // Create new or Update existing (Standard) + '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) - 'ignore' // Try to insert, silently skip duplicates + 'ignore' // Create-only (the default): insert missing rows, leave existing rows alone ]); /** @@ -165,9 +165,40 @@ export const SeedSchema = lazySchema(() => strictObject({ .describe('Field (or composite list of fields) matched for the uniqueness check'), /** - * Import Strategy + * Import Strategy — what a load does with a record whose `externalId` + * matches a row that already exists. + * + * The default, `ignore`, makes a seed create-only: a load inserts the + * records whose row is missing and leaves every existing row exactly as it + * is. A seed is replayed — on every single-tenant boot, on every package + * install or upgrade, on every publish of a `seed` item — and once a seeded + * row reaches a customer the row is the customer's: a replay must not write + * the shipped value back over their edit, silently, on the next restart + * (ruling 6631016988 on objectstack#30081, which measured exactly that on a + * production boot and on both upgrade paths while the default was `upsert`). + * + * 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 + * `columns: { FIELD: { applyOn: 'create' } }` ({@link SeedColumnSchema}). + * + * A package built before this default moved carries a written + * `mode: 'upsert'` in its compiled artifact (`os build` stores the parsed + * seed), and the loader cannot tell that value from an authored one, so such + * a package keeps replaying with upsert until it is rebuilt — and, where it + * was installed with `os package install`, until the rebuilt artifact is + * re-installed, because the install ledger keeps the body as installed and + * its sample-data reseed replays that body. */ - mode: SeedMode.default('upsert').describe('Conflict resolution strategy'), + mode: SeedMode.default('ignore') + .describe( + '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.', + ), /** * Column Policies diff --git a/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts b/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts new file mode 100644 index 00000000000..f0d51861bf6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The declared default of `SeedSchema.mode` moved from `upsert` to `ignore` +// (maintainer ruling 6631016988 on objectstack#30081, option A), and +// `SeedLoaderConfig.defaultMode` became that same field. The ruling asked for +// a production measurement first: on `os start`, `os serve`, a boot-artifact +// upgrade and an `os package install` upgrade, a customer's API edit to a +// seeded row went back to the shipped value on the next replay, with no +// signal (card comment 6633189577). Same class as +// `ui-nav-group-expanded-default-open`: no key is added or removed, so there +// is no tombstone, no RETIRED_KEYS_BY_MAJOR row and no D2 conversion. The +// contract look on the card (6633227800) ruled out a conversion that guesses: +// a compiled artifact's written `mode: "upsert"` cannot be told from an +// authored one. The citations live here, not in the printed guidance, which +// carries no tracker number. +export const entry: SemanticMigration = { + id: 'seed-mode-default-create-only', + surface: 'data.Seed.mode — an OMITTED mode on a seed dataset (and SeedLoaderConfig.defaultMode, which is that same default)', + replacement: 'nothing, to take the platform default: a replay inserts the rows a dataset is missing and ' + + 'leaves every existing row as the customer left it. To keep refreshing a dataset on every replay — ' + + 'reference data the package keeps current — write it: `mode: \'upsert\'`, and exempt the columns ' + + 'the customer or a job owns with `columns: { FIELD: { applyOn: \'create\' } }`', + reason: + 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: a ' + + 'seed is create-only by default. A seed is replayed on every single-tenant boot, on every package ' + + 'install or upgrade and on every publish of a `seed` item, and with the old `upsert` default each ' + + 'replay wrote the shipped value back over a customer\'s edit to a seeded row, in production too, ' + + 'silently. The default is now `ignore`: a missing row is inserted and an existing row is left as ' + + 'it is, so a changed seed value reaches only a row the load creates. Not losslessly convertible, ' + + 'because the question is intent, not text: only the author knows whether a silent dataset is ' + + 'starter data the customer now owns or reference data the package must keep current, and a ' + + 'mechanical pass that wrote `mode: \'upsert\'` onto every silent dataset would restore the ' + + 'overwrite the ruling removed. A package built before this change carries a written ' + + '`mode: "upsert"` in its compiled artifact (`os build` stores the parsed seed); the loader cannot ' + + 'tell that value from an authored one, so such a package keeps replaying with upsert until it is ' + + 'rebuilt, and nothing converts it. A package installed with `os package install` keeps the body it ' + + 'was installed with in its install ledger, and its sample-data reseed replays that body in its ' + + 'stored mode, so an entry installed from such an artifact keeps writing the shipped values back on ' + + 'every reseed until the rebuilt artifact is re-installed: a rebuild alone changes nothing for it. ' + + 'The ledger\'s other replays never overwrite: the boot rehydrate seeds nothing, the heal seeds only ' + + 'when every seeded object is empty, and the per-organization replay runs only for a new ' + + 'organization. A stored `seed` metadata item with no `mode` is not rewritten either: its next ' + + 'publish parses it to `ignore`, so that publish inserts only the rows it is missing where it used ' + + 'to write its values back over existing rows, with no signal. The accept set is unchanged — the ' + + 'same five modes — and every authored `mode` parses exactly as before.', + acceptanceCriteria: + 'A seed dataset that omits `mode` parses to `mode: \'ignore\'`, and `SeedLoaderConfig.defaultMode` ' + + 'parses to `ignore`. Loaded twice with a customer edit in between, such a dataset keeps the edit, ' + + 'inserts a record whose row is missing, and leaves an untouched row\'s `updated_at` as it was. A ' + + 'dataset that declares `mode: \'upsert\'` still writes its declared columns back onto the matched ' + + 'row on every replay. Every dataset that must stay current on replay declares `mode: \'upsert\'`, ' + + 'and every package is rebuilt so its compiled artifact carries the mode its source declares, and ' + + 're-installed wherever it was installed with `os package install`, so its install ledger does too.', +}; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 2ee9f27d96b..f3aa7d2f778 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1316,8 +1316,9 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * using the specified conflict resolution strategy. * * Uses the standard SeedSchema which supports: - * - `externalId`: Idempotency key for upsert matching (default: 'name') - * - `mode`: Conflict resolution (upsert, insert, ignore, replace) + * - `externalId`: Idempotency key that matches a record to an existing row (default: 'name') + * - `mode`: Conflict resolution (default `ignore`, create-only; `upsert` for + * reference data a replay keeps current; `insert`, `update`, `replace`) * - `env`: Environment scoping (prod, dev, test) * * @example @@ -1325,7 +1326,6 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * data: [ * { * object: 'account', - * mode: 'upsert', * externalId: 'name', * records: [ * { name: 'Acme Corp', type: 'customer', industry: 'technology' },