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
8 changes: 8 additions & 0 deletions .changeset/skills-data-seed-modes-landed-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@objectstack/skills': patch
---

docs(skills): the objectstack-data seeds reference now teaches the seed modes the loader runs: `ignore` is the default (create-only), `replace` deletes nothing (it inserts into a table the caller cleared), and `insert` duplicates on each replay. It also says that AppPlugin's boot and organization replays do not re-insert a seed row a person deleted (a publish or package install keeps no record, so the row comes back there), and that `seed-replayer` with `{ reseed: true }` restores it. The data-modeling seed guide teaches the same rule and names the loads that keep no record.

Clause-②: no
Metadata-protocol: none
36 changes: 34 additions & 2 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ customer has since edited.

| Mode | Behavior | Use Case |
|:-----|:---------|:---------|
| `ignore` | **Default.** Create-only: insert a record whose row is missing, leave an existing row exactly as it is | Business and starter data the customer owns once it is loaded |
| `upsert` | Create if new, and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current |
| `ignore` | **Default.** Create-only: insert a record whose row is missing (a boot or organization replay skips one a person [deleted](#a-deleted-seed-row-stays-deleted)), leave an existing row exactly as it is | Business and starter data the customer owns once it is loaded |
| `upsert` | Create if new (a boot or organization replay skips one a person [deleted](#a-deleted-seed-row-stays-deleted)), and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current |
| `insert` | Insert **every** record unconditionally — no existing-row check, so it **duplicates the table on each replay boot** ([#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). Rarely what you want | Append-only rows loaded exactly once (never re-run) |
| `update` | Update only, skip if not found | Migration patches on existing rows |
| `replace` | Insert without checking for an existing match — the seed loader does **not** delete anything itself; the target table must already be empty (or cleared by the caller) | Cache / lookup tables rebuilt from an already-truncated table |
Expand Down Expand Up @@ -153,6 +153,38 @@ check**, so a replayed `insert` dataset duplicates its table on each restart
It is almost never the right mode: leave `mode` unset (create-only), or declare
`upsert` for reference data, with a stable `externalId` instead. `os validate` warns on any `mode: 'insert'` seed.

### A Deleted Seed Row Stays Deleted

Each organization records which seed rows landed in it, by `externalId`, in the
platform object `sys_seed_landed_key`. When a person deletes a seeded row, the
next boot or organization replay does not insert it again: an `ignore` or
`upsert` dataset inserts a missing row only if its key never landed in that
organization. A row an upgrade adds to the seed has never landed, so it
is still inserted. A reference to the deleted row from another seed row is left
unwritten rather than reported as an error.

The record is kept and honoured only where AppPlugin runs the load for a known
organization: the single-tenant boot seed (`objectstack serve`, `os start`, on
every restart; a multi-tenant boot seeds through the per-organization replay
instead), the per-organization replay (`seed-replayer`), and the dev hot reload.
A seed publish, a package install or upgrade (`os package install`, the
marketplace install, its heal and its sample-data reseed) keep no record, so a
deleted row comes back on those. So do a load with no organization, a
composition that does not register `sys_seed_landed_key`
(`@objectstack/platform-objects`), and the marketplace's fallback
`seed-replayer`, which it registers when no app has seed data.

A row deleted before your runtime had this rule comes back once, on the first
replay after the upgrade, and stays deleted from then on.

To bring the deleted rows back on purpose, a host or plugin calls the
`seed-replayer` kernel service with `{ reseed: true }`:

```typescript
const replay = ctx.getService('seed-replayer'); // ctx: the plugin's context
await replay(organizationId, { reseed: true }); // inserts every missing seed row
```

---

## Seeds and State Machines
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
},
{
"id": 4,
"prompt": "Seed the `country` table for every environment; add demo `opportunity` rows (dev/test only) closing 45 days after install, owned by the installer. A nightly job advances `stage`; a seed replay must not reset it.",
"expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; `columns: { stage: { applyOn: 'create' } }` so an `upsert` replay leaves the job-owned column.",
"prompt": "Seed the `country` table for every environment; add demo `opportunity` rows (dev/test only) closing 45 days after install, owned by the installer, and refreshed from the seed on every replay except `stage`: a nightly job advances it, and a replay must not reset it.",
"expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { mode: 'upsert', env: ['dev', 'test'], ... })` for the refreshed demo rows — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; `columns: { stage: { applyOn: 'create' } }` so the `upsert` replay leaves the job-owned column (the `ignore` default would refresh nothing).",
"files": [],
"assertions": {
"must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"],
Expand Down
38 changes: 19 additions & 19 deletions skills/objectstack-data/references/seeds.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

Object definition and seed data live together — a `*.object.ts` usually
pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data).
`defineSeed()` is type-safe: TypeScript checks every record's field keys
against the object definition.

> The factory is `defineSeed` — **not** `defineDataset`, which is the
> unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`).
Expand Down Expand Up @@ -33,7 +31,6 @@ export const statusSeed = defineSeed(Status, {
// Demo data — dev/test only
export const categorySeed = defineSeed(Category, {
externalId: 'slug',
mode: 'upsert',
env: ['dev', 'test'],
records: [
{ slug: 'electronics', name: 'Electronics' },
Expand All @@ -48,8 +45,8 @@ export const SeedData = [statusSeed, categorySeed]; // parents first
| Field | Default | Purpose |
|:------|:--------|:--------|
| `object` | derived | Auto-set from `objectDef.name` — never write manually |
| `externalId` | `'name'` | Stable business key used for upsert / update lookup |
| `mode` | `'upsert'` | Import strategy (see below) |
| `externalId` | `'name'` | Stable business key every keyed mode matches on |
| `mode` | `'ignore'` | Import strategy (see below) |
| `columns` | — | Per-column write policy, keyed by field: `{ FIELD: { applyOn: 'create' } }` (below) |
| `env` | `['prod','dev','test']` | Environments where the seed loads |
| `records` | — | `Partial<Record<keyof object.fields, unknown>>[]` |
Expand All @@ -60,27 +57,31 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`.

| Mode | Behavior | Use for |
|:-----|:---------|:--------|
| `upsert` (default) | Update by `externalId`, insert if missing. Idempotent. | Reference data, bootstrap rows |
| `insert` | Insert all; fail on duplicate `externalId`. | Append-only / audit tables |
| `ignore` (default) | Create-only: insert a missing row, leave a matched row as is. | Data the customer owns |
| `upsert` | Insert if missing; every replay writes declared columns back. | Reference data kept current |
| `insert` | Insert every record, no match: duplicates on each replay. | Rows loaded once |
| `update` | Update only existing rows; never create. | Patching existing config |
| `ignore` | Insert; silently skip duplicates. | Additive bootstrap |
| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data |
| `replace` ⚠️ | Insert with no match; deletes nothing, the caller clears the table. | Cache / lookup tables only |

A seed replays on each boot, install and re-publish. AppPlugin's boot and organization
replays (not a publish or package install) do not re-insert a keyed row a
person deleted; `seed-replayer` with `{ reseed: true }` restores it.

### Columns the app owns after the first load — `columns.FIELD.applyOn`

A seed is replayed (every dev-server boot, every re-publish) and an `upsert`
replay re-asserts every column on the row it matched — right for master data,
wrong for a column a job or an admin advances after the first load. Declare it
An `upsert` replay re-asserts every column on the row it matched — wrong for
a column a job or an admin advances after the first load. Declare it
`applyOn: 'create'`: written when the seed creates the row, left as the row
holds it on every replay (an `update` dataset never writes it; `insert` /
`ignore` / `replace` never update a matched row, so nothing changes there).
holds it on every replay (`update` never writes it; no other mode updates a
matched row).
Each key must be a field of the object or an injected system column —
`defineSeed` refuses one that is not. Same values and meaning as an import
mapping entry's `applyOn` (objectstack-api).

```typescript
defineSeed(Invoice, {
externalId: 'number',
mode: 'upsert',
columns: { status: { applyOn: 'create' } }, // the payment job owns it after the first seed
records: [{ number: 'INV-001', status: 'draft', amount: 1200 }],
});
Expand Down Expand Up @@ -147,17 +148,16 @@ Stdlib in seed context: `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`,
`isBlank(v)`, `coalesce(v, fallback)`. Scope: `os.user`, `os.org`, `os.env`.
See **objectstack-formula** for the full contract.

**Determinism gate:** two consecutive `os build` runs with no source
changes must produce byte-identical `dist/objectstack.json`. CEL + pinned
`now` is what guarantees that — using `Date.now()` will fail CI.
**Determinism gate:** two `os build` runs with no source change must
produce byte-identical `dist/objectstack.json`.

### Seed best practices

| Practice | Why |
|:---------|:----|
| Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise |
| Prefer natural keys (`code` / `email` / `slug`) | Portable across environments |
| Default to `upsert` | Idempotent re-runs |
| Leave `mode` unset; `upsert` only for reference data | Replays keep customer edits |
| Scope demo data with `env: ['dev','test']` | Keep noise out of prod |
| Order seeds parent → child in the exported array | References resolve at load time |
| Use `replace` only on cache/lookup tables, with comments | Data-loss footgun |
| `replace` only on cache tables the caller clears | It never deletes: duplicates otherwise |
Loading