Repository navigation
Commit 8cf527f
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
218 | 226 | | |
219 | 227 | | |
220 | 228 | | |
| |||
504 | 512 | | |
505 | 513 | | |
506 | 514 | | |
507 | | - | |
508 | | - | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
509 | 518 | | |
510 | 519 | | |
511 | 520 | | |
| |||
0 commit comments