Skip to content

Commit 8cf527f

Browse files
docs(seed-data): a locale switch leaves the previous locale's rows resident — the loader only writes (#18502)
Part of #18117. ## What ruling this executes This is the **documentation half** of maintainer decision batch **#133, item 3** — ruling **A** on **#16596**: reconciliation is the operator's job, the seed loader stays write-only, and the card itself rests at `not_planned` (#16596 reads `closed / not_planned` today). Nothing here re-opens that question, and nothing under `packages/**` moves: **this PR is documentation of a decision, not a proposal.** ## Before / after `content/docs/data-modeling/seed-data.mdx`, section `## Locale Scoping` (heading at `:180`). **Before** — `origin/main` `bf61f0a19`, `:212-216`, read 2026-09-16T16:15:04Z (file was 555 lines): > The platform does not translate anything. `locale` only selects between record > sets you authored yourself; both sets stay in your source tree, and the choice > is made when the seeds load rather than when your config is assembled — so > switching markets does not mean rebuilding, and the axis is evaluated in the one > layer that could ever reconcile rows already written for another market. The section ended there (then two callouts about where the loading locale comes from). It said the seed load is the one layer that **could** reconcile rows an earlier market wrote — and never said whether it does. **After** — this branch `08539c3a6`, new paragraph at `:218-224`, read 2026-09-16T16:26Z (file is now 564 lines): > It does not reconcile them, though. As [Import Modes](#import-modes) above > states, the loader never deletes anything itself — every mode only ever > writes. So switching a stack's active locale on a **non-empty** database > loads the new locale's dataset **alongside** the rows the previous locale's > dataset wrote: both sets stay resident and queryable, because the switch is > additive and not a migration. Clearing the market you left behind is the > operator's job — to switch locales cleanly, start from a fresh database. ### Second hunk, same gap, same file — Best Practices `:513-517` `### Ship one dataset per market, not one build per market` carried the other half of the gap. Before (`bf61f0a19` `:505-508`, read 16:15:04Z) it offered residency as a **cost of the alternative**: > Selecting between them in application code instead bakes the choice into your > build output and leaves the other market's rows resident in the database on a switch. Read against the new paragraph that is a false contrast: the `locale` axis leaves the previous market's rows resident too, so a reader comparing the two bullets would take the page to promise that `locale` avoids it. Narrowed to the cost that is real, and pointed at the new paragraph: > 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). No other line in the file moved: `1 file changed, 11 insertions(+), 2 deletions(-)`. ## The three facts and where each is supported | fact | where it is supported | | --- | --- | | the loader only ever writes | `packages/metadata-protocol/src/seed-loader.ts` — **0** row-delete verbs on the engine (measurement below); and the page already says it for one mode: the Import Modes table's `replace` row, `:69`, "the seed loader does **not** delete anything itself". The new paragraph generalises the page's own existing statement rather than asserting a new one. | | a locale switch leaves the previous locale's rows resident | follows from fact 1 plus what the section already documents: `locale` selects **which dataset loads**, evaluated at seed-load time. Nothing in that path removes a row, so the previous dataset's rows survive the switch. | | a clean switch needs a fresh database | the ruling (batch #133 item 3, A): reconciliation is the operator's job and is not a loader capability, so "start from a fresh database" is the only operation the platform offers. | ## Loader re-verification — scope: **the engine**, not the file Taken 2026-09-16T16:15:49Z on tree `bf61f0a19`, `packages/metadata-protocol/src/seed-loader.ts` (2992 lines, unchanged since the card's `57343f76`). Instrument, scoped to calls **on the engine** (`this.engine`, casts included), not to the file: ``` grep -noP '\(?\s*this\.engine\b(\s+as\s+[^)]*\))?\s*\.\s*\K[A-Za-z_]+' # tally grep -nP 'this\.engine\b(\s+as\s+[^)]*\))?\s*\??\.\s*(delete|deleteMany|remove|destroy|truncate)\b' ``` | reading | value | | --- | --- | | row-delete verbs **on the engine** | **0** (grep exit 1, no hits) | | write verbs on the engine (control) | **14** call sites — `insert` 8, `update` 4, `insertMany` 2 | | read verbs on the engine | `find` 3 | | vocabulary control — can the engine delete rows at all? | **yes**: `IDataEngine.delete(objectName, options)` is declared at `packages/spec/src/contracts/data-engine.ts:279`. The capability exists; the loader never calls it. | | instrument positive control | the delete regex matches `this.engine.delete(...)` and `(this.engine as any).deleteMany(...)` in a planted fixture: **2** hits. So "0" is an absence, not a broken pattern. | The broader **file-scoped** spelling `\.(delete|deleteMany|remove|destroy|truncate)\(` returns **1** hit here — `inStack.delete(current)` at `:2509`, a `Set.delete()` on a local cycle-detection stack. That is not a row deletion and not on the engine; the file scope over-counts, which is why the engine-scoped instrument above is the one this PR stands behind. ## Changeset decision — `skip-changeset`, measured (not inherited) The card asserted docs-only ⇒ `skip-changeset`. That reflex was measured wrong elsewhere this shift (`packages/lint`'s `files[]` ships `CHANGELOG.md`), so it was measured here instead. Readings taken 2026-09-16T16:19Z on this branch: | reading | value | | --- | --- | | `package.json` directories that are **ancestors** of `content/docs/data-modeling/seed-data.mdx` | exactly **1**: the repo root (`content/`, `content/docs/`, `content/docs/data-modeling/` hold no manifest) | | that one manifest | `@objectstack/spec-monorepo`, `"private": true`, **no `files[]` key at all** ⇒ publishes nothing | | workspace manifests whose `files[]` names `content/` or escapes its own package directory | **0** (scanned `packages/**`, `apps/*`, `examples/*`) | | instrument positive control | the same scanner reads `packages/lint` → `files: ["dist", "README.md", "CHANGELOG.md"]`, i.e. it does see the #18169 shape; reach: **70 of 76** `packages/**` manifests declare a `files[]` | | `apps/docs` (the site that renders this page) | `@objectstack/docs`, `"private": true`, no `files[]` | ⇒ no released package ships this path, by any manifest in the workspace. `skip-changeset` applies, and the label is on this PR. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `08539c3a6` (path-argument form and the merge-base form produce **identical** lists): **39 families**. All 39 run, each with its exit code recorded; `--ran` reconciliation: `39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero — every family recorded a code and none is 3). - **38 of 39 exit 0.** - Four exited **3 / 1 = PREREQUISITE NOT MET** on the fresh worktree (`check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift` — unbuilt `@objectstack/lint`; `check:docs` — no generated schema tree; `check:skill-examples` — unbuilt `@objectstack/spec` then `@objectstack/client-react`). Built the closures under the shared lock and re-ran: **all exit 0**. - **`pnpm check:cross-package-test-inputs` — the one red, and it is not this PR's.** It exits **0** on this branch before any build and **1** after `packages/spec/dist` exists, flagging `@objectstack/cli descends a directory tree from packages/spec/dist/`. That is card **#18348** (four independent reproductions), reproduced here as a fifth; this diff contains no code file and cannot reach a test-inputs gate. Lock lines (`scripts/pm/os-verify-lock.sh`, slot `dev-18117`), all `VERDICT command-exit 0`: `pnpm install` (8s held), `pnpm --filter '@objectstack/lint...' build` (180s), `pnpm --filter '@objectstack/client-react...' build` (375s). **ESLint — narrowed, and the narrowing is measured.** ① Population, read from `eslint.config.mjs` itself: the lint block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` — `.mdx` is not in it. ② Count, from `--format json`: 1 file reported, **0 errors**, its only message being eslint's own `File ignored because no matching configuration was supplied.` — zero linted files in this diff. ③ Invariance: this repo "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file" (`eslint.config.mjs:327-329`), so a prose edit in a file eslint does not lint cannot move any verdict on an untouched file. The repo-wide `pnpm lint` run stays CI's. No test or typecheck was run: the diff builds and tests no package — the only executable content nearby is prose TypeScript, which `check:skill-examples` type-checks, and it passes (258 marked examples across 106 files, exit 0). --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fed4a15 commit 8cf527f

1 file changed

Lines changed: 11 additions & 2 deletions

File tree

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ is made when the seeds load rather than when your config is assembled — so
215215
switching markets does not mean rebuilding, and the axis is evaluated in the one
216216
layer that could ever reconcile rows already written for another market.
217217

218+
It does not reconcile them, though. As [Import Modes](#import-modes) above
219+
states, the loader never deletes anything itself — every mode only ever
220+
writes. So switching a stack's active locale on a **non-empty** database
221+
loads the new locale's dataset **alongside** the rows the previous locale's
222+
dataset wrote: both sets stay resident and queryable, because the switch is
223+
additive and not a migration. Clearing the market you left behind is the
224+
operator's job — to switch locales cleanly, start from a fresh database.
225+
218226
<Callout type="info">
219227
**Where the loading locale comes from.** On the normal boot path your stack
220228
supplies it: the runtime reads your app's `i18n.defaultLocale` and passes it to
@@ -504,8 +512,9 @@ set `['prod', 'dev', 'test']`).
504512

505513
When the same records need different display strings per language, author both
506514
datasets and scope each with `locale`. Selecting between them in application
507-
code instead bakes the choice into your build output and leaves the other
508-
market's rows resident in the database on a switch.
515+
code instead bakes the choice into your build output, so shipping another
516+
market means another build. Neither approach removes what the market you left
517+
behind already wrote — see [Locale Scoping](#locale-scoping).
509518

510519
### Use `upsert` by default
511520

0 commit comments

Comments
 (0)