Skip to content

Commit c2d6fa8

Browse files
committed
fix(objectql): key i18n bundles by (name, locale) so no member is overwritten (#7730)
`EmailTemplateDefinitionSchema` declares that multiple rows with the same `name` and different `locale` form an i18n bundle, and that a template is resolved by `(name, locale)`. `registerItem` keyed every item by name alone, so the second locale collided with the first and replaced it through the `[Registry] Overwriting email_template: ...` path — a stack authoring en-US and zh-CN copies materialized one row into `sys_email_template`. The storage key now carries the identity the spec declares: a metadata type may declare a discriminator (`ITEM_KEY_DISCRIMINATORS`), and its items are stored under `<packageId>:<name>@<discriminator>`. `email_template`/`locale` is the only entry; every other type keeps name-only identity, byte-identical behaviour, and a pin test asserts it. Reads make the round trip whole rather than moving the defect: a bare-name lookup of a bundled type resolves through the unchanged precedence tiers (ADR-0005 overlay, ADR-0048 prefer-local, first composite) and picks the canonical `en-US` member inside the winning tier, and withdrawal by name takes the whole bundle — matching the consumer side, where `deactivateDeclaredEmailTemplate` sweeps by name across locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3fwc4CLCHphhX4cTcN1KQ
1 parent 8669e5d commit c2d6fa8

3 files changed

Lines changed: 522 additions & 5 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): keep every locale of a declared i18n bundle in the registry (#7730)
6+
7+
`EmailTemplateDefinitionSchema` declares that "multiple rows with the same `name`
8+
but different `locale` form an i18n bundle" and that a template "is resolved by
9+
`(name, locale)`". `SchemaRegistry.registerItem` keyed every item by its name
10+
alone, so the second locale of a name collided with the first, went through the
11+
`[Registry] Overwriting email_template: …` path, and replaced it. A stack
12+
authoring an en-US and a zh-CN copy of one template materialized ONE row into
13+
`sys_email_template`: declared, not enforced. The translated mail simply never
14+
went out, with no error anywhere.
15+
16+
**The key now carries the identity the spec declares.** A metadata type may
17+
declare a discriminator (`ITEM_KEY_DISCRIMINATORS` in `registry.ts`); an item of
18+
such a type is stored under `<packageId>:<name>@<discriminator>`, so the bundle's
19+
members coexist. `email_template` / `locale` is the only entry today, and the
20+
key computation is otherwise byte-identical — every other metadata type keeps
21+
name-only identity and last-write-wins, which a pin test asserts. An item that
22+
declares no locale is keyed as the canonical member, so `{ name }` and
23+
`{ name, locale: 'en-US' }` remain one template and re-registration stays
24+
idempotent.
25+
26+
**Reads make the round trip whole.** Storing both rows is only half a fix if a
27+
lookup then returns an arbitrary one, so a bare-name read of a bundled type
28+
resolves through the same precedence tiers as before — ADR-0005 overlay, then
29+
ADR-0048 prefer-local, then first composite — and picks the canonical (`en-US`)
30+
member inside the winning tier, which is the locale `sendTemplate` already falls
31+
back to. `getArtifactItem` keeps serving the packaged member over an overlay,
32+
and withdrawal by name (`unregisterItem`, `removeOverlayEntry`,
33+
`removeRuntimeShadow`) takes the whole bundle rather than one member, matching
34+
the consumer side where `deactivateDeclaredEmailTemplate` sweeps
35+
`sys_email_template` by name across locales because a delete event carries no
36+
locale.
37+
38+
For an app this shows up as declared email templates finally materializing per
39+
locale: authoring `auth.welcome` in en-US and zh-CN now produces two
40+
`sys_email_template` rows, and `IEmailService.sendTemplate` can pick the
41+
recipient's language instead of whichever locale happened to be declared last.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7730] i18n bundles survive registration — `(name, locale)` is the key.
5+
*
6+
* `EmailTemplateDefinitionSchema` declares that "multiple rows with the same
7+
* `name` but different `locale` form an i18n bundle" and that a template "is
8+
* resolved by `(name, locale)`" (packages/spec/src/system/email-template.zod.ts).
9+
* `registerItem` keyed every item by name alone, so the zh-CN row overwrote the
10+
* en-US one through the `[Registry] Overwriting …` path and only the last
11+
* locale ever reached `sys_email_template`. Declared, not enforced.
12+
*
13+
* The rows are only half the round trip: this file also pins the READ side, so
14+
* a bare-name lookup of a bundle answers the same layer it always did (ADR-0005
15+
* overlay, then ADR-0048 prefer-local, then first composite) and picks the
16+
* canonical locale within it, rather than whichever member the Map iterates
17+
* first.
18+
*/
19+
20+
import { describe, it, expect, beforeEach } from 'vitest';
21+
import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system';
22+
import { SchemaRegistry, ITEM_KEY_DISCRIMINATORS } from './registry';
23+
24+
/** A minimal spec-valid template; `locale` is supplied per case. */
25+
function tpl(name: string, locale: string | undefined, extra: Record<string, unknown> = {}) {
26+
return {
27+
name,
28+
label: `Label ${locale ?? '(default)'}`,
29+
subject: `Subject ${locale ?? '(default)'}`,
30+
bodyHtml: `<p>${locale ?? '(default)'}</p>`,
31+
...(locale === undefined ? {} : { locale }),
32+
...extra,
33+
};
34+
}
35+
36+
describe('SchemaRegistry — i18n bundle keys (#7730)', () => {
37+
let registry: SchemaRegistry;
38+
39+
beforeEach(() => {
40+
registry = new SchemaRegistry({ multiTenant: false });
41+
registry.logLevel = 'silent';
42+
});
43+
44+
describe('the declared bundle materializes', () => {
45+
it('keeps both locales of one name — the reported symptom', () => {
46+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm');
47+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
48+
49+
const listed = registry.listItems<any>('email_template');
50+
expect(listed).toHaveLength(2);
51+
expect(listed.map((t) => t.locale).sort()).toEqual(['en-US', 'zh-CN']);
52+
});
53+
54+
it('is what the `sys_email_template` materializer reads back', () => {
55+
// `bootstrapDeclaredEmailTemplates` (plugin-email) reads
56+
// `registry.listItems('email_template')` and upserts each row on
57+
// `(name, locale)`. Four authored templates over three names — the QA
58+
// stack's shape — must arrive as four rows, not three.
59+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm');
60+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
61+
registry.registerItem('email_template', tpl('auth.reset', 'en-US'), 'name', 'com.acme.crm');
62+
registry.registerItem('email_template', tpl('crm.digest', 'en-US'), 'name', 'com.acme.crm');
63+
64+
const pairs = registry
65+
.listItems<any>('email_template')
66+
.map((t) => `${t.name}@${t.locale}`)
67+
.sort();
68+
expect(pairs).toEqual([
69+
'auth.reset@en-US',
70+
'auth.welcome@en-US',
71+
'auth.welcome@zh-CN',
72+
'crm.digest@en-US',
73+
]);
74+
});
75+
76+
it('treats an omitted `locale` as the canonical member, not a fourth key', () => {
77+
// The schema defaults `locale` to en-US, so `{ name }` and
78+
// `{ name, locale: 'en-US' }` are the SAME template — a re-register, not
79+
// a bundle member. Keying the absent value separately would double-seed.
80+
registry.registerItem('email_template', tpl('auth.welcome', undefined), 'name', 'com.acme.crm');
81+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'second write' }), 'name', 'com.acme.crm');
82+
83+
const listed = registry.listItems<any>('email_template');
84+
expect(listed).toHaveLength(1);
85+
expect(listed[0].subject).toBe('second write');
86+
});
87+
88+
it('still overwrites a genuine same-(name, locale) re-registration', () => {
89+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'first' }), 'name', 'com.acme.crm');
90+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'second' }), 'name', 'com.acme.crm');
91+
92+
const listed = registry.listItems<any>('email_template');
93+
expect(listed).toHaveLength(1);
94+
expect(listed[0].subject).toBe('second');
95+
});
96+
97+
it('keeps two packages shipping the same (name, locale) apart', () => {
98+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'crm' }), 'name', 'com.acme.crm');
99+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'hr' }), 'name', 'com.acme.hr');
100+
101+
expect(registry.listItems<any>('email_template')).toHaveLength(2);
102+
expect(registry.getItem<any>('email_template', 'auth.welcome', 'com.acme.hr')?.subject).toBe('hr');
103+
});
104+
});
105+
106+
describe('the canonical member is the spec default, not a copy of it', () => {
107+
it('pins the registry canonical locale to `EmailTemplateDefinitionSchema`', () => {
108+
// The discriminator table carries the canonical locale as a literal.
109+
// This is the assertion that stops it drifting from the schema default
110+
// it mirrors — change one without the other and this goes red.
111+
const parsed = EmailTemplateDefinitionSchema.parse(tpl('auth.welcome', undefined));
112+
expect(ITEM_KEY_DISCRIMINATORS.email_template.canonical).toBe(parsed.locale);
113+
expect(ITEM_KEY_DISCRIMINATORS.email_template.field).toBe('locale');
114+
});
115+
});
116+
117+
describe('bare-name reads answer the same layer they always did', () => {
118+
it('returns the canonical member regardless of registration order', () => {
119+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
120+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm');
121+
122+
expect(registry.getItem<any>('email_template', 'auth.welcome')?.locale).toBe('en-US');
123+
expect(registry.getItem<any>('email_template', 'auth.welcome', 'com.acme.crm')?.locale).toBe('en-US');
124+
});
125+
126+
it('still resolves a bundle that has no canonical member', () => {
127+
// A stack may localize a template into zh-CN only. Before the key
128+
// carried a locale this resolved because the single row sat on the bare
129+
// name; a bundle-blind read would now answer `undefined` — a regression
130+
// the fix must not introduce.
131+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
132+
133+
expect(registry.getItem<any>('email_template', 'auth.welcome')?.locale).toBe('zh-CN');
134+
});
135+
136+
it('keeps ADR-0005 overlay precedence over the packaged bundle', () => {
137+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged' }), 'name', 'com.acme.crm');
138+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay' }), 'name');
139+
140+
expect(registry.getItem<any>('email_template', 'auth.welcome')?.subject).toBe('overlay');
141+
expect(registry.getItem<any>('email_template', 'auth.welcome', 'com.acme.crm')?.subject).toBe('overlay');
142+
});
143+
144+
it('keeps ADR-0048 prefer-local precedence across packages', () => {
145+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'crm' }), 'name', 'com.acme.crm');
146+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'hr' }), 'name', 'com.acme.hr');
147+
148+
expect(registry.getItem<any>('email_template', 'auth.welcome', 'com.acme.hr')?.subject).toBe('hr');
149+
expect(registry.getItem<any>('email_template', 'auth.welcome', 'com.acme.crm')?.subject).toBe('crm');
150+
});
151+
152+
it('serves the packaged artifact to `getArtifactItem`, never the overlay', () => {
153+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged' }), 'name', 'com.acme.crm');
154+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay' }), 'name');
155+
156+
const artifact = registry.getArtifactItem<any>('email_template', 'auth.welcome');
157+
expect(artifact?.subject).toBe('packaged');
158+
expect(artifact?._packageId).toBe('com.acme.crm');
159+
});
160+
});
161+
162+
describe('withdrawal takes the whole bundle', () => {
163+
it('unregisters every locale of a name', () => {
164+
// Delete events carry `(type, name)` and no locale — the same reason
165+
// `deactivateDeclaredEmailTemplate` sweeps `sys_email_template` by name
166+
// across locales. Leaving a member behind would make it re-seed.
167+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm');
168+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
169+
registry.registerItem('email_template', tpl('auth.reset', 'en-US'), 'name', 'com.acme.crm');
170+
171+
registry.unregisterItem('email_template', 'auth.welcome');
172+
173+
expect(registry.listItems<any>('email_template').map((t) => t.name)).toEqual(['auth.reset']);
174+
expect(registry.getItem<any>('email_template', 'auth.welcome')).toBeUndefined();
175+
});
176+
177+
it('does not take a second package\'s same-named bundle with it', () => {
178+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'crm' }), 'name', 'com.acme.crm');
179+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'crm' }), 'name', 'com.acme.crm');
180+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'hr' }), 'name', 'com.acme.hr');
181+
182+
registry.unregisterItem('email_template', 'auth.welcome');
183+
184+
const left = registry.listItems<any>('email_template');
185+
expect(left).toHaveLength(1);
186+
expect(left[0].subject).toBe('hr');
187+
});
188+
189+
it('removes the overlay members and leaves the packaged bundle serving', () => {
190+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged' }), 'name', 'com.acme.crm');
191+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay' }), 'name');
192+
193+
expect(registry.removeOverlayEntry('email_template', 'auth.welcome')).toBe(true);
194+
expect(registry.getItem<any>('email_template', 'auth.welcome')?.subject).toBe('packaged');
195+
// Idempotent: nothing left to remove.
196+
expect(registry.removeOverlayEntry('email_template', 'auth.welcome')).toBe(false);
197+
});
198+
199+
it('heals the runtime shadow so the packaged bundle becomes visible again', () => {
200+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged' }), 'name', 'com.acme.crm');
201+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay' }), 'name');
202+
203+
expect(registry.removeRuntimeShadow('email_template', 'auth.welcome')).toBe(true);
204+
expect(registry.getItem<any>('email_template', 'auth.welcome')?.subject).toBe('packaged');
205+
// Conservative as before: with no artifact underneath it declines.
206+
registry.registerItem('email_template', tpl('crm.digest', 'en-US'), 'name');
207+
expect(registry.removeRuntimeShadow('email_template', 'crm.digest')).toBe(false);
208+
expect(registry.getItem<any>('email_template', 'crm.digest')).toBeDefined();
209+
});
210+
});
211+
212+
describe('scope — only declared-discriminated types are re-keyed', () => {
213+
it('leaves an undiscriminated type keyed by name alone, even when it carries a `locale`', () => {
214+
// The key computation is generic to every registered metadata type. A
215+
// type whose identity the spec does NOT declare as a pair must keep
216+
// last-write-wins on the name, or this fix would quietly change the
217+
// identity of every other metadata kind.
218+
registry.registerItem('page', { name: 'home', locale: 'en-US', title: 'first' }, 'name', 'com.acme.crm');
219+
registry.registerItem('page', { name: 'home', locale: 'zh-CN', title: 'second' }, 'name', 'com.acme.crm');
220+
221+
const pages = registry.listItems<any>('page');
222+
expect(pages).toHaveLength(1);
223+
expect(pages[0].title).toBe('second');
224+
expect(registry.getItem<any>('page', 'home')?.title).toBe('second');
225+
});
226+
227+
it('declares exactly one discriminated type today', () => {
228+
// A guard on the blast radius: adding a type here re-keys every item of
229+
// that type, so it is a deliberate contract change, not a tweak.
230+
expect(Object.keys(ITEM_KEY_DISCRIMINATORS)).toEqual(['email_template']);
231+
});
232+
});
233+
234+
describe('the #7557 disabled-package gate still sees every member', () => {
235+
it('hides the whole bundle when its owning package is disabled', () => {
236+
// `listItems` filters by each item's `_packageId` (PR #7700). Re-keying
237+
// must not let a bundle member slip past that filter.
238+
registry.installPackage({ id: 'com.acme.crm', name: 'CRM', version: '1.0.0' } as any);
239+
registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm');
240+
registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm');
241+
expect(registry.listItems<any>('email_template')).toHaveLength(2);
242+
243+
registry.disablePackage('com.acme.crm');
244+
expect(registry.listItems<any>('email_template')).toHaveLength(0);
245+
246+
registry.enablePackage('com.acme.crm');
247+
expect(registry.listItems<any>('email_template')).toHaveLength(2);
248+
});
249+
});
250+
});

0 commit comments

Comments
 (0)