Skip to content

Commit 60bfaa8

Browse files
committed
test(seed): pin the locale axis; docs, liveness ledger and changeset
Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude <noreply@anthropic.com>
1 parent f976990 commit 60bfaa8

6 files changed

Lines changed: 461 additions & 1 deletion

File tree

.changeset/seed-locale-axis.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
Seed datasets gain a `locale` filter axis, composed with `env` by the loader.
7+
8+
An app shipping demo data for two language markets — the same records, different display strings — had no declarative way to say which dataset applies. `SeedSchema` is a `strictObject`, so the app could not add the key itself; the selection had to happen in application code while the config was assembled. That is the wrong layer twice over: the choice is cached in the build output (switching markets means deleting `dist`), and because every profile is an `upsert` and the loader only writes, the other market's rows stay resident in the database.
9+
10+
- **`Seed.locale?: string[]`** — BCP-47 tags scoping the dataset to one or more language markets. **Omitted means every locale.** Unlike `env`, whose three environments are a closed set that can be spelled out as a default, locales are open-ended tags with no enumerable universe — so absence, not a default array, is what carries "unrestricted". An empty array is rejected: a dataset that applies nowhere is an authoring mistake, the same reasoning that already governs a composite `externalId`. `locales`, `language` and `languages` are aliased onto it, matching the existing `environment` / `environments``env` pair.
11+
- **`SeedLoaderConfig.locale?: string`** — the tag the load filters on.
12+
- **The loader composes both axes by conjunction.** A dataset is loaded when it passes `env` **and** `locale`; neither axis can rescue a dataset the other excluded. `filterByLocale` mirrors `filterByEnv` down to the reporting posture — skipping is the declared, intended outcome, so it logs at `info`, but it always names what it dropped. Tags compare case-insensitively (BCP-47 casing is a convention, not part of a tag's identity) and otherwise exactly: `['zh']` does not match `zh-CN`, and widening that would be the lenient consumer-side fallback the contract-first rule forbids.
13+
14+
The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them.
15+
16+
**What is not wired yet, stated plainly.** The locale axis is evaluated against `config.locale`, and no first-party call site supplies one — the runtime wiring that would resolve it from the stack's configured locale is a separate change in `packages/runtime`. An embedding host that passes `config.locale` itself gets the full behaviour today; on the default boot path the axis is inert. That is the shape `Seed.env` was in before framework#4704, so it is not left silent: a load carrying locale-scoped datasets and no `config.locale` warns naming each dataset it let through and the config key that would make the scope take effect. The liveness ledger records `seed.locale` as `experimental` for exactly this reason, with the consumer side cited and the producer gap spelled out, rather than claiming `live` on a correct-but-insufficient consumer pointer.

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,53 @@ defineSeed(TestUser, {
177177

178178
---
179179

180+
## Locale Scoping
181+
182+
The `locale` array scopes a dataset to one or more language markets, as BCP-47
183+
tags. It is a second filter axis alongside `env`, and the two **compose**: a
184+
dataset loads when it passes `env` *and* `locale`.
185+
186+
Omitting `locale` means **every locale** — unlike `env` there is no default
187+
array, because locales are open-ended tags with no closed set to spell out.
188+
189+
```typescript
190+
// Reference data — every market (locale omitted)
191+
defineSeed(Country, {
192+
records: [{ code: 'US', name: 'United States' }],
193+
});
194+
195+
// The Chinese market's demo plans
196+
defineSeed(Plan, {
197+
locale: ['zh-CN'],
198+
records: [{ name: '专业版', price: 99 }],
199+
});
200+
201+
// The same plans for English-speaking markets
202+
defineSeed(Plan, {
203+
locale: ['en', 'en-GB'],
204+
records: [{ name: 'Professional', price: 15 }],
205+
});
206+
```
207+
208+
Tags are matched **case-insensitively** (`zh-cn` and `zh-CN` are the same tag)
209+
and otherwise **exactly**`['zh']` does not match a loading locale of
210+
`zh-CN`. List every tag the dataset is for.
211+
212+
The platform does not translate anything. `locale` only selects between record
213+
sets you authored yourself; both sets stay in your source tree, and the choice
214+
is made when the seeds load rather than when your config is assembled — so
215+
switching markets does not mean rebuilding, and the axis is evaluated in the one
216+
layer that could ever reconcile rows already written for another market.
217+
218+
<Callout type="warn">
219+
The axis is evaluated against the seed loader's `config.locale`. A host that
220+
supplies no locale gets **every** dataset, and the loader warns naming each
221+
locale-scoped dataset it let through — so a scope that is not taking effect is
222+
one log line to diagnose rather than a silent no-op.
223+
</Callout>
224+
225+
---
226+
180227
## Type Safety
181228

182229
`defineSeed()` infers valid field keys from the object definition you pass as the
@@ -443,6 +490,13 @@ Keep demo and test-only records out of production by setting `env: ['dev', 'test
443490
System bootstrap data that must exist in production should omit `env` (or explicitly
444491
set `['prod', 'dev', 'test']`).
445492

493+
### Ship one dataset per market, not one build per market
494+
495+
When the same records need different display strings per language, author both
496+
datasets and scope each with `locale`. Selecting between them in application
497+
code instead bakes the choice into your build output and leaves the other
498+
market's rows resident in the database on a switch.
499+
446500
### Use `upsert` by default
447501

448502
`upsert` is idempotent and the safest default. Only change the mode when the use
@@ -477,6 +531,7 @@ function defineSeed<
477531
externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name'
478532
mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
479533
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
534+
locale?: string[]; // BCP-47 tags; omitted = every locale
480535
records: Array<Partial<Record<keyof TObj['fields'], unknown>>>;
481536
}
482537
): Seed

0 commit comments

Comments
 (0)