From 2f6e3124be0fa88bb9f6b70e682aae39437d87fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:33:27 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(spec)!:=20a=20seed=20is=20create-only?= =?UTF-8?q?=20by=20default=20=E2=80=94=20`mode`=20defaults=20to=20`ignore`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replay (every single-tenant boot, a package install or upgrade, a publish of a `seed` item) now inserts the rows a dataset is missing and leaves existing rows alone, so a customer's edit to a seeded row is no longer written back on the next restart. `SeedLoaderConfig.defaultMode` is `SeedSchema.mode` itself, and the six loader call sites (boot inline seed, per-organization replayer, dev hot-reload, package publish apply, protocol publish batch, install-local) plus the seed save door stop restating `'upsert'`. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 8 +++-- .../src/protocol-publish-batch.ts | 4 ++- .../src/protocol-save-door-refusals.ts | 7 ++-- packages/runtime/src/app-plugin.ts | 12 +++++-- packages/runtime/src/domains/packages.ts | 3 +- packages/spec/src/data/seed-loader.zod.ts | 14 ++++++-- packages/spec/src/data/seed.zod.ts | 36 ++++++++++++++++--- 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ec8cba1e9c5..1036b631805 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -4618,7 +4618,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 +4643,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/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/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/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.zod.ts b/packages/spec/src/data/seed.zod.ts index 404d450969f..06dc658e52f 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,37 @@ 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..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. */ - 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 From c10bd0d694c190666071fa94ff46dc26e569cfec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:44:44 +0000 Subject: [PATCH 2/9] chore(spec): declare the seed mode default move and its D3 entry The spec build refuses a moved authorable default until it is declared. Three rows join DEFAULT_CHANGES_BY_MAJOR[18] (Seed.mode, SeedLoaderConfig.defaultMode and the SeedLoaderRequest.config default object), each carrying the ruling, the measurement, the remedy and the built-artifact disposition: a package built before the change keeps replaying with upsert until it is rebuilt, and nothing converts it. The ADR-0087 D3 semantic entry `seed-mode-default-create-only` states the same disposition and why no conversion can guess the author's intent. The authorable-defaults projection and the generated reference pages follow from the build. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- content/docs/references/api/package-api.mdx | 4 +- content/docs/references/data/seed-loader.mdx | 6 +-- content/docs/references/data/seed.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 2 +- packages/spec/authorable-defaults/data.json | 6 +-- packages/spec/scripts/lib/default-changes.ts | 47 +++++++++++++++++++ packages/spec/src/data/seed.zod.ts | 2 +- .../18.seed-mode-default-create-only.ts | 47 +++++++++++++++++++ 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts 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/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..de7d7e0f988 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -58,6 +58,27 @@ 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. D3 entry: ' + + '`seed-mode-default-create-only`.'; + /** * Declared default changes, keyed by the protocol major that shipped them. * @@ -742,5 +763,31 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly strictObject({ * 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..applyOn: 'create'` ({@link SeedColumnSchema}). + * `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 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..f6d054636df --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts @@ -0,0 +1,47 @@ +// 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. 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.', +}; From 1b53a24265791068d967cf5c7eec81cd7f7db7e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:44:50 +0000 Subject: [PATCH 3/9] test: pin the create-only seed default on a real driver Spec: an omitted mode parses to `ignore`, every authored mode parses as written, SeedLoaderConfig.defaultMode equals the seed default, and a mode-less dataset in a parsed SeedLoaderRequest reaches the loader as `ignore`. Runtime, on a real SqlDriver under ObjectQL with the request built the way every replay site now builds it (parsed, no mode of its own): across a replay of a new seed version, a customer edit survives, a record the new version adds is inserted, and a row nobody touched keeps its value and its `updated_at`. Control: the same steps with `mode: 'upsert'` declared write the shipped value back and move the untouched row's `updated_at`. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- ...ult-create-only-replay.integration.test.ts | 181 ++++++++++++++++++ packages/spec/src/data/seed-loader.test.ts | 13 +- packages/spec/src/data/seed.test.ts | 10 +- 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/seed-default-create-only-replay.integration.test.ts 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/spec/src/data/seed-loader.test.ts b/packages/spec/src/data/seed-loader.test.ts index 4552565cb74..81c0a72fee1 100644 --- a/packages/spec/src/data/seed-loader.test.ts +++ b/packages/spec/src/data/seed-loader.test.ts @@ -9,6 +9,7 @@ import { SeedLoaderResultSchema, SeedLoaderRequestSchema, } from './seed-loader.zod'; +import { SeedSchema } from './seed.zod'; // ========================================================================== // ReferenceResolutionSchema @@ -216,12 +217,17 @@ describe('SeedLoaderConfigSchema', () => { 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.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', From 640f7c3372ede2e6785bb2fb7edecd741ee9d715 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:44:54 +0000 Subject: [PATCH 4/9] test(cloud-connection): the install-local reseed follows the seed default The reseed endpoint and the rehydrate healer set no mode of their own. The reseed pin captures the request the handler hands the loader and parses it with the real SeedLoaderRequestSchema: a mode-less dataset arrives as `ignore` and a dataset that declares `mode: 'upsert'` keeps it. The heal test stops expecting the restated `defaultMode: 'upsert'`. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- .../marketplace-install-local-heal.test.ts | 7 +++- .../marketplace-install-local-reseed.test.ts | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) 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-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']); + }); +}); From 7ddbe015338bf67f9870de47d7454598626878d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:48:04 +0000 Subject: [PATCH 5/9] docs(examples): the reference apps' business seeds take the create-only default Census of the in-tree seeds that relied on the overwrite. Every dataset the CRM (5), the showcase (18) and the todo app (1) declared as `mode: 'upsert'` is business or starter data the user edits once it is seeded, so none keeps it: a replay now inserts a missing row and leaves an existing one as the user left it. No in-tree dataset is reference data the package must keep refreshed, and no platform package ships a seed. Each file says why it declares no mode and that `os dev --fresh` is how a seed edit, or a relative date, is seen again. The seed-data guide leads with the create-only default, moves `upsert` to reference data with `applyOn: 'create'` as the escape for owned columns, and states that a package built before the change keeps replaying with upsert until it is rebuilt. The getting-started example and the `data` docblock on the stack follow. Test comments that claimed to mirror the AppPlugin's `defaultMode: 'upsert'` request now say what the request is, and the per-organization request a real-driver test rebuilds drops it. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- content/docs/data-modeling/seed-data.mdx | 72 +++++++++++++------ content/docs/getting-started/examples.mdx | 4 +- examples/app-crm/src/data/index.ts | 13 ++-- examples/app-showcase/src/data/seed/index.ts | 29 +++----- examples/app-todo/src/data/index.ts | 5 +- .../seed-loader-composite-external-id.test.ts | 4 +- .../src/seed-loader-landed-ids.test.ts | 4 +- .../src/seed-loader-replay.test.ts | 4 +- .../seed-loader-state-machine-exempt.test.ts | 4 +- ...ion-holder-real-driver.integration.test.ts | 6 +- ...-organization-identity.integration.test.ts | 2 +- packages/spec/src/stack.zod.ts | 6 +- 12 files changed, 91 insertions(+), 62 deletions(-) diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 3cbdac0ba62..54df4faa347 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,66 @@ 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. Rebuild + 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 +148,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 +666,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 +699,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/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/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/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/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' }, From 6accc0539fc7db7fef75ea46ca97bf8ed30a25e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:48:44 +0000 Subject: [PATCH 6/9] chore(changeset): a seed is create-only by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor for spec, runtime, metadata-protocol and cloud-connection, with the Clause-② (narrowing) and Metadata-protocol: semantic declarations, the ADR-0087 registration marker, the before/now/compiled-artifacts account and the remedy. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- .../30081-seed-mode-default-create-only.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/30081-seed-mode-default-create-only.md 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..aeb12fbd83e --- /dev/null +++ b/.changeset/30081-seed-mode-default-create-only.md @@ -0,0 +1,23 @@ +--- +'@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. +- **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. 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. From 6353695e8fc8d49c15c3299b9ec2cfb4642fd85e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:16:45 +0000 Subject: [PATCH 7/9] test(runtime): the per-organization replayer sets no mode of its own The replayer's request is tenant-scoped and multi-pass, and no longer restates `defaultMode: 'upsert'`: each dataset replays in the mode it declares, the create-only spec default when it declares none. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- packages/runtime/src/seed-datasets.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts index c10aec703bb..974345ff6f8 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 }); }); From b5554b9200e75d50533f9703075b13c19601b9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:52:33 +0000 Subject: [PATCH 8/9] test(runtime): the inline boot seed sets no mode of its own A single-tenant boot's inline seed builds its request with multiPass and no defaultMode, so each dataset replays in the mode it declares. The per-organization replayer and the install-local doors were already pinned the same way; this closes the boot site. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- packages/runtime/src/seed-datasets.test.ts | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts index 974345ff6f8..937db858545 100644 --- a/packages/runtime/src/seed-datasets.test.ts +++ b/packages/runtime/src/seed-datasets.test.ts @@ -247,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'); + }); +}); From 585143c72d6aae36af0119a8e18e4228d4562175 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:58:29 +0000 Subject: [PATCH 9/9] docs(spec,cloud-connection): the install ledger and stored seed items in the disposition The stored-row disposition stopped at the compiled artifact. An `os package install` keeps the body it was installed with in its install ledger, and the sample-data reseed replays that body in its stored mode, so an entry installed from a pre-change 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 rehydrate seeds nothing, the heal seeds only empty objects, the per-organization replay only a new organization). A stored `seed` metadata item with no mode is not rewritten either: its next publish parses it to `ignore`, with no signal. Both are stated in the changeset (with "rebuild and re-install" in the remedy, and purge-then-reseed as the way back to the shipped values), the D3 entry's reason and acceptance criteria, the default-changes ledger reason, the `mode` TSDoc and the seed-data guide's callout. The reseed endpoint's docblock stops promising a clean baseline over edited rows. Claude-Session: https://claude.ai/code/session_01WGufziyEchnN2d8EyH3Jc6 Co-authored-by: Claude --- .../30081-seed-mode-default-create-only.md | 4 +++- content/docs/data-modeling/seed-data.mdx | 8 +++++--- .../src/marketplace-install-local-plugin.ts | 16 +++++++++++----- packages/spec/scripts/lib/default-changes.ts | 12 ++++++++++-- packages/spec/src/data/seed.zod.ts | 5 ++++- .../semantic/18.seed-mode-default-create-only.ts | 15 ++++++++++++--- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/.changeset/30081-seed-mode-default-create-only.md b/.changeset/30081-seed-mode-default-create-only.md index aeb12fbd83e..e496783079b 100644 --- a/.changeset/30081-seed-mode-default-create-only.md +++ b/.changeset/30081-seed-mode-default-create-only.md @@ -18,6 +18,8 @@ Metadata-protocol: semantic - **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. 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. +**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 54df4faa347..769c05d23be 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -119,9 +119,11 @@ customer or a job owns, mark those columns create-only — see 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. Rebuild - to take the create-only default; declare `mode: 'upsert'` on the datasets that must - stay current. + 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 diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 1036b631805..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 diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index de7d7e0f988..4aa649575c2 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -76,8 +76,16 @@ const SEED_MODE_CREATE_ONLY_REASON = + '`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. D3 entry: ' - + '`seed-mode-default-create-only`.'; + + '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. diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index d9958e87d64..dc7831d364a 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -185,7 +185,10 @@ export const SeedSchema = lazySchema(() => strictObject({ * 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. + * 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('ignore') .describe( 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 index f6d054636df..f0d51861bf6 100644 --- 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 @@ -35,13 +35,22 @@ export const entry: SemanticMigration = { + '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. The accept set is unchanged — the same five modes — and every ' - + 'authored `mode` parses exactly as before.', + + '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 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.', };