diff --git a/.changeset/28830-layered-read-pair-added-member.md b/.changeset/28830-layered-read-pair-added-member.md new file mode 100644 index 00000000000..8b38ab0e8c5 --- /dev/null +++ b/.changeset/28830-layered-read-pair-added-member.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +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) + +Clause-②: yes (widening) +Metadata-protocol: none + +`GET /meta/email_template//layers?package=&member=` (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. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a0d28ad53ac..e73030bd1d6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11518,12 +11518,7 @@ export class ObjectStackProtocolImplementation implements // added member is the address's, served from the pair's row under // the pair's binding (a dependent's member never is). if (!record && readState === 'active' && wantedMember !== undefined && typeof contentPackageId === 'string') { - const pairOfAddition = this.pairOfAddedMember(request.type, request.name, contentPackageId, wantedMember); - if (pairOfAddition !== undefined) { - record = (await this.findServedOverlayRow({ - type: request.type, name: request.name, state: readState, packageId: pairOfAddition, ...memberOption(member), - }, activeRows))?.row; - } + record = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember, activeRows); } // [#23715] Two or more packages and no package-unbound draft: the // best-effort first match ADR-0048 §3.3 states, as before, served @@ -12223,7 +12218,7 @@ export class ObjectStackProtocolImplementation implements // then the `.custom` item, `code` the base's document and // `effective` the override (the base's document stays reachable // here, ADR-0146 "Decided here" item 5). - const served = await this.findServedOverlayRow({ + let served: { row: any; overrideOf?: string } | undefined = await this.findServedOverlayRow({ type: request.type, name: request.name, state: 'active', @@ -12231,6 +12226,13 @@ export class ObjectStackProtocolImplementation implements ...memberOption(member), servedOverride: true, }); + // [#28830, #28390 ruling A] The pair's own added member is the + // overlay on the base's address, as the by-name read serves it + // ({@link findPairAddedMemberRow}); another package's never is. + if (!served && wantedMember !== undefined && typeof contentPackageId === 'string') { + const row = await this.findPairAddedMemberRow(request.type, request.name, contentPackageId, member, wantedMember); + if (row) served = { row }; + } overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, name: request.name, @@ -17406,6 +17408,24 @@ export class ObjectStackProtocolImplementation implements return pair.ok ? pair.value : undefined; } + /** + * [#28802, #28830] The pair's own stored row at the bundle member `member` + * (`wantedMember` its discriminator value) of the name `name`, when only + * the pair can add that member on `base`'s address + * ({@link pairOfAddedMember}); else `undefined`. The one read of it the + * by-name read and the layered read share. + */ + private async findPairAddedMemberRow( + type: string, name: string, base: string, member: string | undefined, wantedMember: string, + activeRows?: readonly StoredOverlayRow[], + ): Promise { + const pairOfAddition = this.pairOfAddedMember(type, name, base, wantedMember); + if (pairOfAddition === undefined) return undefined; + return (await this.findServedOverlayRow({ + type, name, state: 'active', packageId: pairOfAddition, ...memberOption(member), + }, activeRows))?.row; + } + /** * [#28802] The registry's answer `item` for a bundle member read on the * address of `base`, when that address serves it diff --git a/packages/objectql/src/protocol-bundle-member-base-address.test.ts b/packages/objectql/src/protocol-bundle-member-base-address.test.ts index ebf256cdd13..a0d92ba0211 100644 --- a/packages/objectql/src/protocol-bundle-member-base-address.test.ts +++ b/packages/objectql/src/protocol-bundle-member-base-address.test.ts @@ -345,3 +345,72 @@ describe('[#28802] a bundle member on the base\'s address, at member grain', () expect(await executing(protocol, NAME)).toEqual([['en-US', BASE, 'Override EN'], PAIR_FR]); }); }); + +/** The layered read: `{ code, overlay, effective }` subjects (or view columns), or `404` when no layer answers. */ +const layersOf = async (protocol: ObjectStackProtocolImplementation, type: string, name: string, member?: string, packageId?: string) => { + try { + const res: any = await (protocol as any).getMetaItemLayered({ + type, name, ...(member ? { member } : {}), ...(packageId ? { packageId } : {}), + }); + const pick = (item: any) => (item === null || item === undefined + ? null + : type === 'view' + ? (item?.config?.columns ?? item?.columns ?? []).map((c: any) => c?.field ?? c) + : [item.subject, item.locale]); + // The protocol answers no layer at all as three nulls; the `/layers` door turns that into 404. + if (res.code == null && res.overlay == null && res.effective == null) return 404; + return { code: pick(res.code), overlay: pick(res.overlay), effective: pick(res.effective) }; + } catch (error: any) { + if (error?.status === 404) return 404; + throw error; + } +}; + +describe('[#28830] the layered read on the base\'s address, at member grain', () => { + it('the pair\'s own added member answers with the pair\'s layer, naming the base and naming no package', async () => { + const { protocol } = await boot(); + await install(protocol, PAIR); + await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR'); + + const PAIR_LAYERS = { code: null, overlay: ['Pair FR', 'fr-FR'], effective: ['Pair FR', 'fr-FR'] }; + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE)).toEqual(PAIR_LAYERS); + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR')).toEqual(PAIR_LAYERS); + // Unchanged: the pair's own address, and the base's member. + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', PAIR)).toEqual(PAIR_LAYERS); + expect(await layersOf(protocol, TYPE, NAME, 'en-US', BASE)) + .toEqual({ code: ['Base EN', 'en-US'], overlay: null, effective: ['Base EN', 'en-US'] }); + }); + + it('a dependent\'s added member stays 404 on the base\'s layered read and answers under the dependent', async () => { + const { protocol } = await boot(); + await install(protocol, DEPENDENT); + await publishInto(protocol, DEPENDENT, NAME, 'fr-FR', 'Dependent FR'); + + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE)).toBe(404); + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR')).toBe(404); + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', DEPENDENT)) + .toEqual({ code: null, overlay: ['Dependent FR', 'fr-FR'], effective: ['Dependent FR', 'fr-FR'] }); + }); + + it('with a dependent\'s member at the same locale, the base\'s layered read answers the pair\'s', async () => { + const { protocol } = await boot(); + await install(protocol, PAIR); + await install(protocol, DEPENDENT); + await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR'); + await publishInto(protocol, DEPENDENT, NAME, 'fr-FR', 'Dependent FR'); + + expect(await layersOf(protocol, TYPE, NAME, 'fr-FR', BASE)) + .toEqual({ code: null, overlay: ['Pair FR', 'fr-FR'], effective: ['Pair FR', 'fr-FR'] }); + }); + + it('control: a declared override of the base\'s member, and a non-bundle type, answer as before', async () => { + const { protocol } = await boot(); + await install(protocol, PAIR); + await publishInto(protocol, PAIR, NAME, 'en-US', 'Override EN', { overrides: { package: BASE, basedOn: '1.0.0' } }); + + expect(await layersOf(protocol, TYPE, NAME, 'en-US', BASE)) + .toEqual({ code: ['Base EN', 'en-US'], overlay: ['Override EN', 'en-US'], effective: ['Override EN', 'en-US'] }); + expect(await layersOf(protocol, 'view', VIEW, undefined, BASE)) + .toEqual({ code: BASE_COLUMNS, overlay: null, effective: BASE_COLUMNS }); + }); +}); diff --git a/packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts b/packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts index 7102ad980ea..fd389b2be8a 100644 --- a/packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts @@ -75,6 +75,14 @@ function showcaseRig() { const item = (doc?.item ?? doc) as Record; return { status: read.status, subject: item.subject, locale: item.locale, _packageId: item._packageId }; }; + /** `GET /meta/email_template/:name/layers` at one member — the subjects of `code`, `overlay` and `effective`, or the status. */ + const layers = async (member: string, pkg?: string) => { + const read = await call('GET', `/meta/email_template/${NAME}/layers?member=${member}${pkg ? `&package=${pkg}` : ''}`); + if (read.status !== 200) return { status: read.status }; + const doc = read.json?.data ?? read.json; + const subject = (layer: any) => (layer === null || layer === undefined ? null : layer.subject); + return { status: 200, code: subject(doc?.code), overlay: subject(doc?.overlay), effective: subject(doc?.effective) }; + }; const rowsOf = (items: any[]) => items .filter((i) => i?.name === NAME) .map((i) => [i.locale, i._packageId, i.subject]) @@ -133,7 +141,7 @@ function showcaseRig() { const doc = read.json?.data ?? read.json?.item ?? read.json; rig.shipped = doc?.item ?? doc; }; - return { rig, boot, byName, listed, executing, rendered, body, saveInto, publishInto, createDependent, ensurePair }; + return { rig, boot, byName, layers, listed, executing, rendered, body, saveInto, publishInto, createDependent, ensurePair }; } 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 expect(await r.byName('en-US')).toEqual(BASE_EN); }); + 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 () => { + expect(await r.layers('fr-FR', BASE)).toEqual({ status: 404 }); + expect(await r.layers('fr-FR')).toEqual({ status: 404 }); + expect(await r.layers('fr-FR', DEPENDENT)) + .toEqual({ status: 200, code: null, overlay: DEPENDENT_SUBJECT, effective: DEPENDENT_SUBJECT }); + // Control: the base's own member. + expect(await r.layers('en-US', BASE)) + .toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: null, effective: SHIPPED_SUBJECT }); + }); + 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 () => { expect(await r.listed()).toEqual([BASE_EN_ROW]); 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 expect(await r.byName('en-US')).toEqual(OVERRIDE_EN); expect(await r.listed()).toEqual([['en-US', BASE, OVERRIDE_SUBJECT]]); expect(await r.rendered('en-US')).toBe('Override: T'); + expect(await r.layers('en-US', BASE)) + .toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: OVERRIDE_SUBJECT, effective: OVERRIDE_SUBJECT }); + expect(await r.layers('fr-FR', BASE)).toEqual({ status: 404 }); expect(await r.byName('fr-FR', BASE)).toEqual({ status: 404 }); 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 expect(await r.byName('en-US')).toEqual(BASE_EN); }); + 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 () => { + const PAIR_LAYERS = { status: 200, code: null, overlay: PAIR_SUBJECT, effective: PAIR_SUBJECT }; + expect(await r.layers('fr-FR', BASE)).toEqual(PAIR_LAYERS); + expect(await r.layers('fr-FR')).toEqual(PAIR_LAYERS); + // Unchanged: the pair's own address, and the base's member. + expect(await r.layers('fr-FR', PAIR)).toEqual(PAIR_LAYERS); + expect(await r.layers('en-US', BASE)) + .toEqual({ status: 200, code: SHIPPED_SUBJECT, overlay: null, effective: SHIPPED_SUBJECT }); + }); + 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 () => { expect(await r.listed()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]); 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 expect(await r.byName('fr-FR', DEPENDENT)).toEqual(DEPENDENT_FR); expect(await r.byName('fr-FR', BASE)).toEqual(PAIR_FR); expect(await r.byName('fr-FR')).toEqual(PAIR_FR); + expect(await r.layers('fr-FR', BASE)) + .toEqual({ status: 200, code: null, overlay: PAIR_SUBJECT, effective: PAIR_SUBJECT }); expect(await r.listed()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]); expect(await r.listed(BASE)).toEqual([BASE_EN_ROW, PAIR_FR_ROW]); expect(await r.executing()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);