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
10 changes: 10 additions & 0 deletions .changeset/28830-layered-read-pair-added-member.md
Original file line number Diff line number Diff line change
@@ -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/<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.
34 changes: 27 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -12223,14 +12218,21 @@ 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',
...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}),
...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,
Expand Down Expand Up @@ -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<any> {
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
Expand Down
69 changes: 69 additions & 0 deletions packages/objectql/src/protocol-bundle-member-base-address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ function showcaseRig() {
const item = (doc?.item ?? doc) as Record<string, unknown>;
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])
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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]);
Expand All @@ -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 });
Expand Down Expand Up @@ -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]);
Expand All @@ -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]);
Expand Down
Loading