Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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