Skip to content

Commit 3e62a22

Browse files
fix(metadata-protocol): the layered read on a base's address answers an email template locale its .custom pair adds with the pair's layer (#28830) (#28845)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XNUzphnasmraGeKrcFVQSpZ)_ Fixes #28830 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:邮件模板的分层视图(`/meta/email_template/<名称>/layers`)按基础包名或不带包名读取时,客户在 `BASE.custom` 里新增的语言(基础包没有的,比如 showcase 只发 `en-US`,客户加 `fr-FR`)现在返回配对包那一层(`code` 为空、`overlay` 与 `effective` 是客户的模板),和按名读取、列表、发信一致;依赖包新增的语言在基础包地址下仍是 404,按依赖包名读取才有。 - **为什么改**:PR #28828 之后,配对包新增的语言在按名读取、列表和发信里都由基础包地址提供,只有分层视图还是 404,作者在后台看不到自己改了什么(#28830,PR #28828 合同复核提的「分层读取」标记)。 - **风险与回滚**:低。只改读取侧的一处分支,复用 #28828 已有的同一判定(`pairOfAddedMember`),只在名单类型(`ITEM_KEY_DISCRIMINATORS`,目前只有 `email_template`)且某语言基础包不发时才生效;其它类型字节不变,写入闸门不变。回滚即撤销本 PR。 - **先看**:`packages/metadata-protocol/src/protocol.ts` 的 `[#28830]` 一处(`getMetaItemLayered`)和新的私有方法 `findPairAddedMemberRow`(按名读取与分层读取共用的一次读取)。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/email-template-dependent-unshipped-locale-read.dogfood.test.ts`,预期 12/12 通过(main 上 2 红)。 - **需要你做的**:无,席位落地。 ## What changes Since PR #28828 (option A of #28779, ruling A on #28390), a base's address serves an email template member its `.custom` pair adds at a locale the base does not ship: the by-name reads naming the base or no package, the env-wide and base-scoped lists, `getMetaItemsForExecution` and `sendTemplate`. The layered read did not: `GET /meta/email_template/<name>/layers?package=<base>&member=fr-FR` answered `404` (no code layer, since the base ships no `fr-FR`, and no overlay, since the served-row candidates of the base's address hold no row at that member). ADR-0146 D8 lists the reads that must agree; this brings the layered read in line. | layered read on the base's address (`?package=BASE` or no package) | before | after | |:--|:--|:--| | base's own member (`en-US`) | `code`: base, `overlay`: null | unchanged | | pair's declared override (`en-US`) | `code`: base, `overlay`: override | unchanged | | pair's own added member (`fr-FR`) | `404` | **`code: null`, `overlay`/`effective`: the pair's row** | | a dependent's added member (`fr-FR`) | `404` | `404` (unchanged; answers under `?package=DEPENDENT`) | | pair and dependent both at `fr-FR` | `404` | **the pair's** | | any non-bundle type | as before | unchanged (the branch needs a bundle member) | **`packages/metadata-protocol/src/protocol.ts`.** - `getMetaItemLayered`: when the served-row resolution finds no row and the read addresses a bundle member on a package's address, the pair's own row at that member is the overlay, read by `findPairAddedMemberRow`. `code` stays as resolved (null: the base ships no such member), so `effective` is the pair's row. - `findPairAddedMemberRow` (new, private): the one read of the pair's row at an added member. It asks PR #28828's `pairOfAddedMember` (the base ships the name and not the member, else `undefined`) and then `findServedOverlayRow` naming the pair. `readMetaItem`'s #28802 branch now calls it too, so the by-name read and the layered read share one predicate and one read (behaviour of the by-name read unchanged: its suite and both test files stay green). - Another package's member never answers here: `pairOfAddedMember` names only the base's `.custom` pair, so a dependent's row is not read on the base's address. ## Tests - `packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts` (real showcase over HTTP, `showcase_task_done_email`): a `layers(member, pkg?)` probe on the rig and 2 new cases, plus layered assertions folded into 2 existing ones. - Pair: the layered read naming the base, naming no package and naming the pair answers `{code: null, overlay: pair's subject, effective: pair's subject}` for `fr-FR`; the base's `en-US` layers unchanged. - Dependent: `fr-FR` stays 404 on the base and package-less layered reads and answers under `?package=DEPENDENT`; base `en-US` unchanged. - Pair beside dependent at `fr-FR`: the base's layered read answers the pair's. - Control: the pair's declared `en-US` override answers `{code: shipped, overlay: override, effective: override}`, and `fr-FR` on the base with only that override stored is 404. - RED on main `1920e480`: 2 failed / 10 passed (the pair case and the pair-beside-dependent case). GREEN at `c53513be`: 12/12 (os-verify-lock VERDICT command-exit 0). - `packages/objectql/src/protocol-bundle-member-base-address.test.ts` (real protocol, real engine, in-memory driver): a `layersOf` helper and a `[#28830]` describe with 4 cases: the pair's added member on the base, package-less and pair addresses; a dependent's member 404 on the base and answered under the dependent; pair and dependent at one locale; control (declared `en-US` override, and a non-bundle `view` answering `{code: base columns, overlay: null}` as before). GREEN 10/10 at `c53513be`. - Ablation, one leg (the seam): `if (row) served = { row };` → `void row;` through `scripts/ablation-replace.mjs` (anchor 1 → 0, restore blob == HEAD verified), `metadata-protocol` rebuilt and the mutation proved absent from `dist/` by `ablation-dist-preflight.mjs --absent`. Unit: 2 red (the pair's added member; pair beside dependent), 8 green. Dogfood: 2 red (the same two), 10 green. The dependent cases and every control stay green. A restore build followed, the marker proved present again. - Package suites at this head: see Verification. ## Acceptance notes - **The `version` token (measured, not changed).** On main `1920e480`, showcase, pair-added `fr-FR`: the by-name read naming the base answers `version: null`; the package-less and pair-named reads answer the pair row's token (`hmac-sha256:a8a29081…`); the base's own `en-US` read naming the base answers `null` (no stored row at the base's address). `version` is `readVersionToken` at the request's address, so naming the base it reads the base's row at `fr-FR`, which does not exist. Not the same seam (the token is the OCC pin of a save at that address, and a save naming the base at a member only the pair adds is the write gate's question), so it is not changed here; reported for the seat. - **`Metadata-protocol: none`.** Nothing an author can write changed (no key, value or metadata type added or refused) and no meaning changed: what a pair-added locale means on the base's address was decided by #28828; this read now reports it. Changeset `patch`. - **Stamp.** The layered read reports layers, not a `_packageId` stamp of its own; the `overlay` is the pair's stored document as stored. - **`overlayRow`** (what `declinesStoredRow` judges a shipped flow name on) is set from the pair's row too; it only matters for `flow`, which is no bundle type. - **Overlap re-read** before `pr_create`: open PRs touching `packages/metadata-protocol/src/` are #28731 (`protocol.ts`, the by-id OCC comparison near line 14450), #28764 (`metadata-redaction.ts`) and #28838 (one test file). `git merge-tree` against this head: no conflict with any; none touches `readMetaItem`, `getMetaItemLayered` or the #28802 helpers. Seat 1's #28831 (the per-package layer's provenance) has no open PR yet. ## Verification Package suites at `c53513be`: `@objectstack/metadata-protocol` typecheck + test (297 files passed, 4 skipped; 22080 tests passed), `@objectstack/objectql` typecheck + test (521 files, 10008 tests passed), both under the lock, VERDICT command-exit 0. Gate sweep at `c53513be`: `dispatch-gates --commands` derived 66 families (the same 66 as PR #28828); 66 run, all exit 0 (`dispatch-gates --ran` reads "66 run, 0 NOT-MEASURED", a derived zero). One first ran exit 3 on a prerequisite and was re-run green: `check:dual-build-cjs-loads` (seven packages outside the dogfood closure were unbuilt). The record is `pm-orders/server/28830-gates-ran.txt` in the project files. Full dogfood battery: not run. The arm is `(widening)` and the title carries no `!`; the two touched test files ran as above. Changeset `.changeset/28830-layered-read-pair-added-member.md`: `@objectstack/metadata-protocol` patch, both declaration lines. CI families are read from this PR's check-runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw Co-authored-by: Claude <noreply@anthropic.com>
1 parent b95a856 commit 3e62a22

4 files changed

Lines changed: 140 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the layered read on a base's address answers an email template locale added in the base's `.custom` pair with the pair's layer (#28830)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`GET /meta/email_template/<name>/layers?package=<base>&member=<locale>` (and the same read naming no package) answered `404` for a locale the base does not ship and its `.custom` pair adds, while the by-name read, the base's lists and `sendTemplate` serve that locale since #28802. It now answers the pair's row as the `overlay` and `effective` layers (`code: null`, the base ships no such locale), from the one read the by-name read uses. Another package's added locale stays `404` there and answers under the read naming that package; the base's own locales, the pair's declared overrides and every type whose identity is its name alone are unchanged.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11630,12 +11630,7 @@ export class ObjectStackProtocolImplementation implements
1163011630
// added member is the address's, served from the pair's row under
1163111631
// the pair's binding (a dependent's member never is).
1163211632
if (!record && readState === 'active' && wantedMember !== undefined && typeof contentPackageId === 'string') {
11633-
const pairOfAddition = this.pairOfAddedMember(request.type, request.name, contentPackageId, wantedMember);
11634-
if (pairOfAddition !== undefined) {
11635-
record = (await this.findServedOverlayRow({
11636-
type: request.type, name: request.name, state: readState, packageId: pairOfAddition, ...memberOption(member),
11637-
}, activeRows))?.row;
11638-
}
11633+
record = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember, activeRows);
1163911634
}
1164011635
// [#23715] Two or more packages and no package-unbound draft: the
1164111636
// best-effort first match ADR-0048 §3.3 states, as before, served
@@ -12335,14 +12330,21 @@ export class ObjectStackProtocolImplementation implements
1233512330
// then the `.custom` item, `code` the base's document and
1233612331
// `effective` the override (the base's document stays reachable
1233712332
// here, ADR-0146 "Decided here" item 5).
12338-
const served = await this.findServedOverlayRow({
12333+
let served: { row: any; overrideOf?: string } | undefined = await this.findServedOverlayRow({
1233912334
type: request.type,
1234012335
name: request.name,
1234112336
state: 'active',
1234212337
...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}),
1234312338
...memberOption(member),
1234412339
servedOverride: true,
1234512340
});
12341+
// [#28830, #28390 ruling A] The pair's own added member is the
12342+
// overlay on the base's address, as the by-name read serves it
12343+
// ({@link findPairAddedMemberRow}); another package's never is.
12344+
if (!served && wantedMember !== undefined && typeof contentPackageId === 'string') {
12345+
const row = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember);
12346+
if (row) served = { row };
12347+
}
1234612348
overlayLockLayer = await this.overlayLockLayerAt({
1234712349
type: request.type,
1234812350
name: request.name,
@@ -17525,6 +17527,24 @@ export class ObjectStackProtocolImplementation implements
1752517527
return pair.ok ? pair.value : undefined;
1752617528
}
1752717529

17530+
/**
17531+
* [#28802, #28830] The pair's own stored row at the bundle member `member`
17532+
* (`wantedMember` its discriminator value) of the name `name`, when only
17533+
* the pair can add that member on `base`'s address
17534+
* ({@link pairOfAddedMember}); else `undefined`. The one read of it the
17535+
* by-name read and the layered read share.
17536+
*/
17537+
private async findPairAddedMemberRow(
17538+
type: string, name: string, base: string, member: string | undefined, wantedMember: string,
17539+
activeRows?: readonly StoredOverlayRow[],
17540+
): Promise<any> {
17541+
const pairOfAddition = this.pairOfAddedMember(type, name, base, wantedMember);
17542+
if (pairOfAddition === undefined) return undefined;
17543+
return (await this.findServedOverlayRow({
17544+
type, name, state: 'active', packageId: pairOfAddition, ...memberOption(member),
17545+
}, activeRows))?.row;
17546+
}
17547+
1752817548
/**
1752917549
* [#28802] The registry's answer `item` for a bundle member read on the
1753017550
* address of `base`, when that address serves it

‎packages/objectql/src/protocol-bundle-member-base-address.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,72 @@ describe('[#28802] a bundle member on the base\'s address, at member grain', ()
345345
expect(await executing(protocol, NAME)).toEqual([['en-US', BASE, 'Override EN'], PAIR_FR]);
346346
});
347347
});
348+
349+
/** The layered read: `{ code, overlay, effective }` subjects (or view columns), or `404` when no layer answers. */
350+
const layersOf = async (protocol: ObjectStackProtocolImplementation, type: string, name: string, member?: string, packageId?: string) => {
351+
try {
352+
const res: any = await (protocol as any).getMetaItemLayered({
353+
type, name, ...(member ? { member } : {}), ...(packageId ? { packageId } : {}),
354+
});
355+
const pick = (item: any) => (item === null || item === undefined
356+
? null
357+
: type === 'view'
358+
? (item?.config?.columns ?? item?.columns ?? []).map((c: any) => c?.field ?? c)
359+
: [item.subject, item.locale]);
360+
// The protocol answers no layer at all as three nulls; the `/layers` door turns that into 404.
361+
if (res.code == null && res.overlay == null && res.effective == null) return 404;
362+
return { code: pick(res.code), overlay: pick(res.overlay), effective: pick(res.effective) };
363+
} catch (error: any) {
364+
if (error?.status === 404) return 404;
365+
throw error;
366+
}
367+
};
368+
369+
describe('[#28830] the layered read on the base\'s address, at member grain', () => {
370+
it('the pair\'s own added member answers with the pair\'s layer, naming the base and naming no package', async () => {
371+
const { protocol } = await boot();
372+
await install(protocol, PAIR);
373+
await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR');
374+
375+
const PAIR_LAYERS = { code: null, overlay: ['Pair FR', 'fr-FR'], effective: ['Pair FR', 'fr-FR'] };
376+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE)).toEqual(PAIR_LAYERS);
377+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR')).toEqual(PAIR_LAYERS);
378+
// Unchanged: the pair's own address, and the base's member.
379+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', PAIR)).toEqual(PAIR_LAYERS);
380+
expect(await layersOf(protocol, TYPE, NAME, 'en-US', BASE))
381+
.toEqual({ code: ['Base EN', 'en-US'], overlay: null, effective: ['Base EN', 'en-US'] });
382+
});
383+
384+
it('a dependent\'s added member stays 404 on the base\'s layered read and answers under the dependent', async () => {
385+
const { protocol } = await boot();
386+
await install(protocol, DEPENDENT);
387+
await publishInto(protocol, DEPENDENT, NAME, 'fr-FR', 'Dependent FR');
388+
389+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE)).toBe(404);
390+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR')).toBe(404);
391+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', DEPENDENT))
392+
.toEqual({ code: null, overlay: ['Dependent FR', 'fr-FR'], effective: ['Dependent FR', 'fr-FR'] });
393+
});
394+
395+
it('with a dependent\'s member at the same locale, the base\'s layered read answers the pair\'s', async () => {
396+
const { protocol } = await boot();
397+
await install(protocol, PAIR);
398+
await install(protocol, DEPENDENT);
399+
await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR');
400+
await publishInto(protocol, DEPENDENT, NAME, 'fr-FR', 'Dependent FR');
401+
402+
expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE))
403+
.toEqual({ code: null, overlay: ['Pair FR', 'fr-FR'], effective: ['Pair FR', 'fr-FR'] });
404+
});
405+
406+
it('control: a declared override of the base\'s member, and a non-bundle type, answer as before', async () => {
407+
const { protocol } = await boot();
408+
await install(protocol, PAIR);
409+
await publishInto(protocol, PAIR, NAME, 'en-US', 'Override EN', { overrides: { package: BASE, basedOn: '1.0.0' } });
410+
411+
expect(await layersOf(protocol, TYPE, NAME, 'en-US', BASE))
412+
.toEqual({ code: ['Base EN', 'en-US'], overlay: ['Override EN', 'en-US'], effective: ['Override EN', 'en-US'] });
413+
expect(await layersOf(protocol, 'view', VIEW, undefined, BASE))
414+
.toEqual({ code: BASE_COLUMNS, overlay: null, effective: BASE_COLUMNS });
415+
});
416+
});

‎packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ function showcaseRig() {
7575
const item = (doc?.item ?? doc) as Record<string, unknown>;
7676
return { status: read.status, subject: item.subject, locale: item.locale, _packageId: item._packageId };
7777
};
78+
/** `GET /meta/email_template/:name/layers` at one member — the subjects of `code`, `overlay` and `effective`, or the status. */
79+
const layers = async (member: string, pkg?: string) => {
80+
const read = await call('GET', `/meta/email_template/${NAME}/layers?member=${member}${pkg ? `&package=${pkg}` : ''}`);
81+
if (read.status !== 200) return { status: read.status };
82+
const doc = read.json?.data ?? read.json;
83+
const subject = (layer: any) => (layer === null || layer === undefined ? null : layer.subject);
84+
return { status: 200, code: subject(doc?.code), overlay: subject(doc?.overlay), effective: subject(doc?.effective) };
85+
};
7886
const rowsOf = (items: any[]) => items
7987
.filter((i) => i?.name === NAME)
8088
.map((i) => [i.locale, i._packageId, i.subject])
@@ -133,7 +141,7 @@ function showcaseRig() {
133141
const doc = read.json?.data ?? read.json?.item ?? read.json;
134142
rig.shipped = doc?.item ?? doc;
135143
};
136-
return { rig, boot, byName, listed, executing, rendered, body, saveInto, publishInto, createDependent, ensurePair };
144+
return { rig, boot, byName, layers, listed, executing, rendered, body, saveInto, publishInto, createDependent, ensurePair };
137145
}
138146

139147
const BASE_EN = { status: 200, subject: SHIPPED_SUBJECT, locale: 'en-US', _packageId: BASE };
@@ -179,6 +187,16 @@ describe('[#28802] a dependent\'s email template locale the base does not ship s
179187
expect(await r.byName('en-US')).toEqual(BASE_EN);
180188
});
181189

190+
it('[#28830] the layered read naming the base or no package stays 404 for the dependent\'s `fr-FR`; naming the dependent it answers', async () => {
191+
expect(await r.layers('fr-FR', BASE)).toEqual({ status: 404 });
192+
expect(await r.layers('fr-FR')).toEqual({ status: 404 });
193+
expect(await r.layers('fr-FR', DEPENDENT))
194+
.toEqual({ status: 200, code: null, overlay: DEPENDENT_SUBJECT, effective: DEPENDENT_SUBJECT });
195+
// Control: the base's own member.
196+
expect(await r.layers('en-US', BASE))
197+
.toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: null, effective: SHIPPED_SUBJECT });
198+
});
199+
182200
it('the env-wide, base-scoped and executing lists leave the dependent\'s `fr-FR` out, so a `fr-FR` send renders the base\'s `en-US`', async () => {
183201
expect(await r.listed()).toEqual([BASE_EN_ROW]);
184202
expect(await r.listed(BASE)).toEqual([BASE_EN_ROW]);
@@ -195,6 +213,9 @@ describe('[#28802] a dependent\'s email template locale the base does not ship s
195213
expect(await r.byName('en-US')).toEqual(OVERRIDE_EN);
196214
expect(await r.listed()).toEqual([['en-US', BASE, OVERRIDE_SUBJECT]]);
197215
expect(await r.rendered('en-US')).toBe('Override: T');
216+
expect(await r.layers('en-US', BASE))
217+
.toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: OVERRIDE_SUBJECT, effective: OVERRIDE_SUBJECT });
218+
expect(await r.layers('fr-FR', BASE)).toEqual({ status: 404 });
198219

199220
expect(await r.byName('fr-FR', BASE)).toEqual({ status: 404 });
200221
expect(await r.byName('fr-FR')).toEqual({ status: 404 });
@@ -223,6 +244,16 @@ describe('[#28802] the pair\'s own email template locale the base does not ship
223244
expect(await r.byName('en-US')).toEqual(BASE_EN);
224245
});
225246

247+
it('[#28830] the layered read naming the base or no package answers the pair\'s `fr-FR` layer, as the other reads serve it', async () => {
248+
const PAIR_LAYERS = { status: 200, code: null, overlay: PAIR_SUBJECT, effective: PAIR_SUBJECT };
249+
expect(await r.layers('fr-FR', BASE)).toEqual(PAIR_LAYERS);
250+
expect(await r.layers('fr-FR')).toEqual(PAIR_LAYERS);
251+
// Unchanged: the pair's own address, and the base's member.
252+
expect(await r.layers('fr-FR', PAIR)).toEqual(PAIR_LAYERS);
253+
expect(await r.layers('en-US', BASE))
254+
.toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: null, effective: SHIPPED_SUBJECT });
255+
});
256+
226257
it('the env-wide, base-scoped and executing lists carry the pair\'s `fr-FR` beside the base\'s `en-US`, so a `fr-FR` send renders it', async () => {
227258
expect(await r.listed()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);
228259
expect(await r.listed(BASE)).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);
@@ -240,6 +271,8 @@ describe('[#28802] the pair\'s own email template locale the base does not ship
240271
expect(await r.byName('fr-FR', DEPENDENT)).toEqual(DEPENDENT_FR);
241272
expect(await r.byName('fr-FR', BASE)).toEqual(PAIR_FR);
242273
expect(await r.byName('fr-FR')).toEqual(PAIR_FR);
274+
expect(await r.layers('fr-FR', BASE))
275+
.toEqual({ status: 200, code: null, overlay: PAIR_SUBJECT, effective: PAIR_SUBJECT });
243276
expect(await r.listed()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);
244277
expect(await r.listed(BASE)).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);
245278
expect(await r.executing()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);

0 commit comments

Comments
 (0)