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
25 changes: 25 additions & 0 deletions .changeset/30081-seed-mode-default-create-only.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: registered seed-mode-default-create-only -->

**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.
74 changes: 51 additions & 23 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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).

<Callout type="info">
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.
</Callout>

### `replace` — Insert Into an Already-Cleared Table

```typescript
Expand All @@ -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.

---

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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' },
Expand Down
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: `"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<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 publish and install paths 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: `"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<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 publish and install paths do not filter by locale — they load every dataset and warn |
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/data/seed-loader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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<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 publish and install paths do not filter by locale — they load every dataset and warn |
Expand All @@ -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 |
Expand Down
Loading
Loading