Skip to content
16 changes: 16 additions & 0 deletions .changeset/seed-locale-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": minor
---

Seed datasets gain a `locale` filter axis, composed with `env` by the loader.

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.

- **`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.
- **`SeedLoaderConfig.locale?: string`** — the tag the load filters on.
- **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.

The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them.

**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.
55 changes: 55 additions & 0 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,53 @@ defineSeed(TestUser, {

---

## Locale Scoping

The `locale` array scopes a dataset to one or more language markets, as BCP-47
tags. It is a second filter axis alongside `env`, and the two **compose**: a
dataset loads when it passes `env` *and* `locale`.

Omitting `locale` means **every locale** — unlike `env` there is no default
array, because locales are open-ended tags with no closed set to spell out.

```typescript
// Reference data — every market (locale omitted)
defineSeed(Country, {
records: [{ code: 'US', name: 'United States' }],
});

// The Chinese market's demo plans
defineSeed(Plan, {
locale: ['zh-CN'],
records: [{ name: '专业版', price: 99 }],
});

// The same plans for English-speaking markets
defineSeed(Plan, {
locale: ['en', 'en-GB'],
records: [{ name: 'Professional', price: 15 }],
});
```

Tags are matched **case-insensitively** (`zh-cn` and `zh-CN` are the same tag)
and otherwise **exactly** — `['zh']` does not match a loading locale of
`zh-CN`. List every tag the dataset is for.

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.

<Callout type="warn">
The axis is evaluated against the seed loader's `config.locale`. A host that
supplies no locale gets **every** dataset, and the loader warns naming each
locale-scoped dataset it let through — so a scope that is not taking effect is
one log line to diagnose rather than a silent no-op.
</Callout>

---

## Type Safety

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

### Ship one dataset per market, not one build per market

When the same records need different display strings per language, author both
datasets and scope each with `locale`. 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.

### Use `upsert` by default

`upsert` is idempotent and the safest default. Only change the mode when the use
Expand Down Expand Up @@ -477,6 +531,7 @@ function defineSeed<
externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name'
mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
locale?: string[]; // BCP-47 tags; omitted = every locale
records: Array<Partial<Record<keyof TObj['fields'], unknown>>>;
}
): Seed
Expand Down
3 changes: 3 additions & 0 deletions content/docs/references/data/seed-loader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Seed data loader configuration
| **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 |
| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |

Expand Down Expand Up @@ -228,6 +229,7 @@ Seed loader request with datasets and configuration
| **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 |
| **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 |
| **records** | `Record<string, any>[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand All @@ -248,6 +250,7 @@ Seed loader request with datasets and configuration
| **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 |
| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/seed.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const result = SeedSchema.parse(data);
| **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 |
| **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 |
| **records** | `Record<string, any>[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Structured plugin permission grants (ADR-0025 §3.2)
| **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 |
| **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 |
| **records** | `Record<string, any>[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down
Loading
Loading