Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .changeset/meta-list-i18n-bundle-key.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/metadata-protocol": patch
"@objectstack/metadata-core": patch
"@objectstack/objectql": patch
---

fix(metadata-protocol): keep every i18n bundle member through the `/meta` list merge (#7774)

#7730 taught the `SchemaRegistry` that an `email_template`'s identity is
`(name, locale)`, so `listItems('email_template')` returns every member of a
declared i18n bundle. `GET /meta/<type>` then merges that listing with two
higher layers, and both merges keyed by `(package, name)` with no
discriminator — so the bundle survived registration only to collapse one layer
later, and the list served a single locale.

**Both merges now key on the pair.** `metaItemKey` takes an optional third
component and `mergePackageAwareOverlay` buckets per slot rather than per name;
both derive the value from the shared discriminator table, and both are
byte-identical for a type that declares no discriminator — which is every type
except `email_template` today.

- **The MetadataService merge** is the path the issue named: with a `metadata`
service installed and answering non-empty for the type, the second member's
`Map.set` overwrote the first.
- **The `sys_metadata` overlay merge** was predicted to need no change, on the
ground that overlay rows are unique on `type+name+organization_id+package_id`
and carry no locale column. That is true of the rows and beside the point:
the base of that merge is the registry's bundle, so bucketing by bare name
dropped a locale as soon as a single overlay row existed for the type — and
the row that survived was the overlay body, whichever member it customizes.
An overlay (or a draft preview) now lands on its own locale member and the
rest of the bundle is served untouched. Across the env-wide and org tiers,
rows that customize different members are likewise two slots instead of one;
org-over-env precedence is unchanged within a member.

**The discriminator table moved to `@objectstack/metadata-core`.**
`ITEM_KEY_DISCRIMINATORS` was declared in `@objectstack/objectql`'s
`registry.ts`, and `@objectstack/objectql` depends on
`@objectstack/metadata-protocol`, so the protocol package could not import it
without closing a dependency cycle. metadata-core is the package both already
depend on and depends on neither — the same criterion that sank the engine
write-verb dispatch predicates (#5619) and the audit-field governance table
(#4513) there. **No public surface changes:** `registry.ts` re-exports
`ITEM_KEY_DISCRIMINATORS` under its original name from its original module, so
every existing import keeps working; `@objectstack/metadata-core` gains it plus
`readDiscriminatorValue` / `itemDiscriminator` as additive exports. The
registry's storage-key *format* (`name@<locale>` composite keys and their
parser) deliberately did not move — it encodes the registry's own Map keys,
which no other package reads.

For an app this is Studio's metadata list and `GET /meta/email_template`
showing both the en-US and the zh-CN copy of a template instead of whichever
one the merge happened to keep.
10 changes: 10 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,3 +53,13 @@ export * from './injected-system-columns.js';
// outlet, or the mask is decoration") is only true if they all run the same
// projection rather than a copy each.
export * from './object-schema-fls.js';

// [#7730 / #7774] The i18n-bundle DISCRIMINATOR table — which metadata types
// are identified by `(name, <field>)` rather than by `name` alone — sunk here
// by the same criterion as the governance table above. `@objectstack/objectql`
// (the SchemaRegistry, #7730) and `@objectstack/metadata-protocol` (the
// unscoped `/meta` list merge, #7774) both key metadata by name, objectql
// depends on metadata-protocol, and a bundle that survives one layer's key but
// not the other's is still collapsed. `objectql` re-exports
// `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so its surface is unchanged.
export * from './item-key-discriminators.js';
92 changes: 92 additions & 0 deletions packages/metadata-core/src/item-key-discriminators.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#7730 / #7774] i18n bundles — metadata types whose IDENTITY is a pair.
*
* Most metadata types are identified by `name` alone. `email_template`
* declares otherwise: `EmailTemplateDefinitionSchema` states that "multiple
* rows with the same `name` but different `locale` form an i18n bundle; the
* service picks the best match for the recipient's locale, falling back to
* `en-US`" (`packages/spec/src/system/email-template.zod.ts`), and its header
* says a template "is resolved by `(name, locale)`".
*
* Every layer that keys metadata by `name` therefore has to agree on ONE
* answer to "what else is part of this type's identity?", or a bundle survives
* one layer and collapses at the next:
*
* - `@objectstack/objectql`'s `SchemaRegistry` keys its item collections
* (`registerItem` / `getItem` / `listItems`) — fixed by #7730;
* - `@objectstack/metadata-protocol`'s unscoped `/meta/<type>` list merge
* keys the sys_metadata overlay and MetadataService merges (`metaItemKey`,
* `mergePackageAwareOverlay`) — fixed by #7774, which is why the table
* lives HERE rather than in the registry that first needed it.
*
* `@objectstack/objectql` depends on `@objectstack/metadata-protocol`, so the
* protocol package cannot import the table from the registry — the reverse
* import would close a cycle turbo rejects. This package is the one both sides
* already depend on and it depends on neither, the same criterion that sank
* the engine write-verb dispatch predicates (#5619), the audit-field
* governance table (#4513) and the injected-system-column definitions (#6562)
* here. `objectql` re-exports `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so
* its public surface is unchanged.
*
* What deliberately did NOT move: the registry's storage-key FORMAT
* (`BUNDLE_KEY_SEPARATOR`, `withDiscriminator`, `bundleBaseKey`,
* `collectBundle`). Those encode a discriminator into the registry's own
* `<packageId>:<name>` Map keys and parse it back out again; the protocol
* layer builds its own NUL-separated merge key and never parses a registry
* key, so it needs the IDENTITY question answered — the table and
* {@link itemDiscriminator} — not the encoding. Moving the encoding too would
* have published a registry-internal key format as a cross-package contract.
*/

/**
* Metadata types whose identity is `(name, <discriminator field>)`.
*
* The discriminator is declared PER TYPE rather than duck-typed off a `locale`
* property, because the key computation is generic to every registered
* metadata type: reading whatever `item.locale` happened to be set would
* silently re-key any other type that grows a locale-ish field, which is a much
* larger contract change than the one this table makes. `email_template` is the
* only type whose schema declares a top-level `locale` that is part of its
* identity.
*
* `canonical` is the bundle member a bare-name read resolves to, and the value
* a member that declares no discriminator is keyed as. It mirrors the schema's
* own `locale` default and `sendTemplate`'s documented fallback;
* `registry-i18n-bundle-key.test.ts` (objectql) pins the two together so this
* copy cannot drift from the spec.
*/
export const ITEM_KEY_DISCRIMINATORS: Readonly<Record<string, { field: string; canonical: string }>> = {
email_template: { field: 'locale', canonical: 'en-US' },
};

/**
* The discriminator value an item declares, trimmed; `''` when it declares
* none. `content[field]` is consulted too, because a stored metadata body may
* carry the definition nested under `content`.
*/
export function readDiscriminatorValue(item: unknown, field: string): string {
const holder = item as Record<string, any> | null | undefined;
const raw = holder?.[field] ?? holder?.content?.[field];
return typeof raw === 'string' ? raw.trim() : '';
}

/**
* The canonical-normalized discriminator of `item` under `type`, or
* `undefined` when `type` declares none.
*
* `undefined` is the load-bearing return: every caller appends this to a key
* ONLY when it is defined, so an undiscriminated type's key stays
* byte-identical to what it was before this table existed. A discriminated
* item that declares no value is keyed as the `canonical` member — the same
* row a bare-name read resolves to — so the bundle-blind and bundle-aware
* answers agree for a single-member "bundle".
*
* @param type Singular metadata type name (`'email_template'`, not the plural).
*/
export function itemDiscriminator(type: string, item: unknown): string | undefined {
const disc = ITEM_KEY_DISCRIMINATORS[type];
if (!disc) return undefined;
return readDiscriminatorValue(item, disc.field) || disc.canonical;
}
Loading
Loading