Skip to content

Commit c1d8f98

Browse files
hotlongclaude
andauthored
feat(spec, metadata-protocol): add a locale filter axis to seed datasets, composed with env (#16592)
* feat(spec, metadata-protocol): add a locale axis to seed datasets WIP — schema + loader halves; tests, docs and changeset to follow. Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude <noreply@anthropic.com> * 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> * chore(spec): regenerate reference docs, skill refs and liveness counts Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude <noreply@anthropic.com> * chore: re-anchor system-context census lines and pin the new engine doubles Both are mechanical, gate-driven repairs of this change's own side effects: the +51 lines in seed-loader.ts moved three cited anchors, and the new locale-scope test pins engine doubles the contract ledger had not recorded. Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude <noreply@anthropic.com> * chore(spec): regenerate liveness state counts after the second main sync `gen:liveness-counts` recomputed from the merged ledger: the only stale artifact this round. The total row is the union of both sides — main's #16784 re-grade of ActionSchema operation/patch (planned -> live, 848 -> 850) plus this branch's experimental `seed.locale` row (exp 5 -> 6, classified 958 -> 959). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mr75Roij7XFWE6Cn2UyBq6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d958b34 commit c1d8f98

15 files changed

Lines changed: 659 additions & 5 deletions

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

content/docs/references/data/seed-loader.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ Seed data loader configuration
196196
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
197197
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
198198
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
199+
| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
199200
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
200201
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |
201202

@@ -228,6 +229,7 @@ Seed loader request with datasets and configuration
228229
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
229230
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
230231
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
232+
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
231233
| **records** | `Record<string, any>[]` || Data records |
232234
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
233235
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -248,6 +250,7 @@ Seed loader request with datasets and configuration
248250
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
249251
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
250252
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
253+
| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
251254
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
252255
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |
253256

content/docs/references/data/seed.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const result = SeedSchema.parse(data);
3131
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
3232
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
3333
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
34+
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
3435
| **records** | `Record<string, any>[]` || Data records |
3536
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
3637
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

content/docs/references/kernel/manifest.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Structured plugin permission grants (ADR-0025 §3.2)
8888
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
8989
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
9090
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
91+
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
9192
| **records** | `Record<string, any>[]` || Data records |
9293
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
9394
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

0 commit comments

Comments
 (0)