diff --git a/.changeset/28802-bundle-member-base-address.md b/.changeset/28802-bundle-member-base-address.md
new file mode 100644
index 00000000000..0c35d909c38
--- /dev/null
+++ b/.changeset/28802-bundle-member-base-address.md
@@ -0,0 +1,16 @@
+---
+'@objectstack/metadata-protocol': minor
+---
+
+fix(metadata-protocol)!: an email template locale added in a `.custom` pair is listed and sent on the base's address, and a dependent's added locale is served only under its own package (#28802)
+
+Clause-②: yes (narrowing)
+Metadata-protocol: semantic
+
+
+
+**BREAKING** read-side narrowing on a base's address, with one widening beside it. An email template is one item per `(name, locale)` (`ITEM_KEY_DISCRIMINATORS`), and since #28746 a locale the base does not ship is admitted as an addition, from the base's `.custom` pair (no `overrides`) and from a dependent package. The reads decided at name grain.
+
+- **Widened.** The pair's own added locale is served on the base's address: the env-wide `GET /meta/email_template`, the list scoped to the base, `getMetaItemsForExecution` (which the email template loader reads) and the by-name reads naming the base or no package, stamped with the pair's id. So `renderTemplate` / `sendTemplate` in that locale renders the customer's template. It was read by name but never listed, so a send rendered the base's `en-US`.
+- **Narrowed.** Another package's added locale under a base template's name (a dependent's, or any package other than the base and its pair) is no longer served by the by-name reads naming the base or no package (`404`): those reads fell to the registry's cross-package first match (ADR-0048 §3.3) and answered that package's body. It is still served by the read naming that package and the list scoped to it; the base's lists already left it out.
+- **Unchanged.** The base's own locales and the pair's declared overrides of them; the list scoped to the pair; the write gate (#28746); every type whose identity is its name alone.
diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts
index e4cc31f8920..343ebc5f831 100644
--- a/packages/metadata-protocol/src/protocol-environment-rows.ts
+++ b/packages/metadata-protocol/src/protocol-environment-rows.ts
@@ -16,7 +16,8 @@
* the code packages that ship the name. Not a moved body: the rule the two
* by-name reads share. [#28409] …and `shippingPackageIds` /
* `soleShippingPackage`, the code packages that ship a name, read one way by
- * those reads and by the env-wide list.
+ * those reads and by the env-wide list. [#28802] …and
+ * `baseAddressServesMember`, the member grain both apply to a bundle.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
@@ -43,6 +44,7 @@
* not move.
*/
+import { customizationPackageId } from '@objectstack/spec/kernel';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import type { StoredOverlayRow } from './item-lock.js';
import { overlayMemberWhere } from './migrations/overlay-index.js';
@@ -255,3 +257,24 @@ export function soleShippingPackage(shipped: readonly unknown[]): string | undef
const shippers = shippingPackageIds(shipped);
return shippers.size === 1 ? [...shippers][0] : undefined;
}
+
+/**
+ * [#28802, ADR-0146 D2, #28390 ruling A] Whether the address of `base`, a
+ * code package that ships a bundle's name (a type in
+ * `ITEM_KEY_DISCRIMINATORS`), serves a member of that name bound to `bound`.
+ * Member grain: the base's own members and a member bound to no package, and
+ * the pair's own ADDED members, a member of the name the base does not ship
+ * (`baseShipsMember` false), as the pair is the customer layer the base's
+ * address serves. A member another package binds under the base's name (a
+ * dependent's addition) is that package's own item, served under its own
+ * address only: "a dependent adds items under its own names". So is a pair's
+ * undeclared row at a member the base ships (refused at the write gate since
+ * PR #28777). A declared override is served as the base's item by the
+ * served-row resolution and is never asked about here.
+ */
+export function baseAddressServesMember(base: string, bound: string | undefined, baseShipsMember: boolean): boolean {
+ if (bound === undefined || bound === base) return true;
+ if (baseShipsMember) return false;
+ const pair = customizationPackageId(base);
+ return pair.ok && pair.value === bound;
+}
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 2f90da6acfd..89a2dae4235 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -471,7 +471,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js';
// [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it.
import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js';
// [#25467 cut 35] The environment's own `sys_metadata` rows: the family read, the one-row read, the served row and the package-less address.
-import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, soleShippingPackage } from './protocol-environment-rows.js';
+import { baseAddressServesMember, findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, shippingPackageIds, soleShippingPackage } from './protocol-environment-rows.js';
// [#25467 cut 35] The stored rows of a collection, folded over the registered ones.
import { foldStoredCollection } from './protocol-stored-collection-fold.js';
// [#25467 cut 35] The environment-wide package uninstall verb.
@@ -10492,6 +10492,24 @@ export class ObjectStackProtocolImplementation implements
// MetadataService not available or doesn't support this type
}
+ // [#28802, #28390 ruling A] …except, for a bundle the package ships
+ // (`ITEM_KEY_DISCRIMINATORS`), its pair's row at a member the package
+ // does not ship: the pair's own added member, which the package's
+ // address serves beside the package's members
+ // ({@link baseServesBundleMember}), as the env-wide list below does.
+ if (unseated && servedOverride && packageId && ITEM_KEY_DISCRIMINATORS[request.type] !== undefined) {
+ const pairOfList = customizationPackageId(packageId);
+ const typeShipping = this.shippingPackagesOf(request.type);
+ for (const it of items as any[]) {
+ if (!(it && typeof it === 'object' && unseated.has(it))) continue;
+ if (!pairOfList.ok || boundPackageOf(it._packageId) !== pairOfList.value || typeof it.name !== 'string') continue;
+ if (this.shipsBundleName(request.type, it.name, packageId, typeShipping)
+ && this.baseServesBundleMember(request.type, it.name, packageId, it, typeShipping)) {
+ unseated.delete(it);
+ }
+ }
+ }
+
// [#21817] A stand-in no layer seated is a package-less row of an item
// the package does not ship: it is not in the package's list.
if (unseated) {
@@ -10524,8 +10542,16 @@ export class ObjectStackProtocolImplementation implements
// every item of a name no package or several packages ship (ADR-0048
// §3.3). The `package` type is never narrowed: its items are the
// packages themselves.
+ //
+ // [#28802, #28390 ruling A] A bundle's name (`ITEM_KEY_DISCRIMINATORS`)
+ // is narrowed at MEMBER grain: the sole shipper's list also keeps its
+ // pair's own added members, a member the base does not ship
+ // ({@link baseServesBundleMember}), so a locale the customer adds in
+ // `.custom` is listed and executed (the email loader reads this list).
+ // A dependent's member under the name is still dropped here.
if (servedOverride && !packageId && request.type !== 'package') {
const typeShipping = this.shippingPackagesOf(request.type);
+ const isBundle = ITEM_KEY_DISCRIMINATORS[request.type] !== undefined;
const soleShipperOf = new Map();
items = (items as any[]).filter((it) => {
const itemName = it?.name;
@@ -10535,7 +10561,8 @@ export class ObjectStackProtocolImplementation implements
soleShipperOf.set(itemName, soleShippingPackage(this.shippedArtifactsOf(request.type, itemName, typeShipping)));
}
const soleShipper = soleShipperOf.get(itemName);
- return soleShipper === undefined || soleShipper === bound;
+ if (soleShipper === undefined || soleShipper === bound) return true;
+ return isBundle && this.baseServesBundleMember(request.type, itemName, soleShipper, it, typeShipping);
});
}
@@ -11486,6 +11513,18 @@ export class ObjectStackProtocolImplementation implements
...(readState === 'active' ? { servedOverride: true } : {}),
}, activeRows);
let record = served?.row;
+ // [#28802, #28390 ruling A] A member of a bundle the address's
+ // package ships and does not ship at that member: the pair's own
+ // 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;
+ }
+ }
// [#23715] Two or more packages and no package-unbound draft: the
// best-effort first match ADR-0048 §3.3 states, as before, served
// with `version: null` (no row at the address the save naming none
@@ -11711,6 +11750,15 @@ export class ObjectStackProtocolImplementation implements
if (this.isStoredFlowEntryOfShippedName(request.type, item)) {
item = this.lookupArtifactItem(request.type, request.name, request.packageId);
}
+ // [#28802, #28390 ruling A] A bundle member on a base's address
+ // (the package named, else the one whose content a read naming
+ // none serves) is one that address serves: the registry's last
+ // tier, the first composite match across packages (ADR-0048 §3.3),
+ // no longer answers a dependent's member under the base's name.
+ const memberAddress = request.packageId || (typeof contentPackageId === 'string' ? contentPackageId : undefined);
+ if (item !== undefined && wantedMember !== undefined && memberAddress !== undefined) {
+ item = this.registryMemberServedAt(request.type, request.name, memberAddress, item, member);
+ }
}
// [#5840] The MetadataService half of the #5532 rule, and the last
@@ -17325,6 +17373,59 @@ export class ObjectStackProtocolImplementation implements
return shippedArtifactsOf.call(host, type, name, shipping, member);
}
+ /** [#28802] Whether `base` is a code package that ships the bundle's name `name`, any member. */
+ private shipsBundleName(type: string, name: string, base: string, shipping: ShippingPackages): boolean {
+ return shippingPackageIds(this.shippedArtifactsOf(type, name, shipping)).has(base);
+ }
+
+ /**
+ * [#28802, ADR-0146 D2, #28390 ruling A] Whether the address of `base`, a
+ * code package that ships the bundle's name `name` (a type in
+ * `ITEM_KEY_DISCRIMINATORS`), serves `item`, a member of that name: by
+ * {@link baseAddressServesMember}, given whether `base` ships the member
+ * `item` is. The one member-grain answer the env-wide list, the list
+ * scoped to `base` and the by-name reads give.
+ */
+ private baseServesBundleMember(type: string, name: string, base: string, item: unknown, shipping: ShippingPackages): boolean {
+ const member = itemDiscriminator(canonicalMetaType(type), item);
+ const baseShipsMember = shippingPackageIds(this.shippedArtifactsOf(type, name, shipping, member)).has(base);
+ return baseAddressServesMember(base, boundPackageOf((item as { _packageId?: unknown } | null | undefined)?._packageId), baseShipsMember);
+ }
+
+ /**
+ * [#28802, #28390 ruling A] The pair of `base` when the bundle member
+ * `member` (its discriminator value) of the name `name` is one only the
+ * pair can add on `base`'s address: `base` ships the name and not that
+ * member. Else `undefined`, and the by-name read serves as before.
+ */
+ private pairOfAddedMember(type: string, name: string, base: string, member: string): string | undefined {
+ const shipping = this.shippingPackagesOf(type);
+ if (!this.shipsBundleName(type, name, base, shipping)) return undefined;
+ if (shippingPackageIds(this.shippedArtifactsOf(type, name, shipping, member)).has(base)) return undefined;
+ const pair = customizationPackageId(base);
+ return pair.ok ? pair.value : undefined;
+ }
+
+ /**
+ * [#28802] The registry's answer `item` for a bundle member read on the
+ * address of `base`, when that address serves it
+ * ({@link baseServesBundleMember}); else, for a read that named no
+ * member, the base's own member its artifact answers canonical-first,
+ * and otherwise nothing. An address that ships no member of the name is
+ * no base of it: `item` as before.
+ */
+ private registryMemberServedAt(type: string, name: string, base: string, item: unknown, member: string | undefined): unknown {
+ const shipping = this.shippingPackagesOf(type);
+ if (!this.shipsBundleName(type, name, base, shipping)) return item;
+ if (this.baseServesBundleMember(type, name, base, item, shipping)) return item;
+ if (member !== undefined) return undefined;
+ const own = this.shippedArtifactsOf(type, name, shipping)
+ .find((artifact) => (artifact as { _packageId?: unknown } | null)?._packageId === base);
+ if (own === undefined) return undefined;
+ const candidate = this.engine.registry.getItem(type, name, base, itemDiscriminator(canonicalMetaType(type), own));
+ return candidate !== undefined && this.baseServesBundleMember(type, name, base, candidate, shipping) ? candidate : undefined;
+ }
+
/**
* [#21803] The packages that can ship an item of `type`, read off the
* registry's own listings:
diff --git a/packages/objectql/src/protocol-bundle-member-base-address.test.ts b/packages/objectql/src/protocol-bundle-member-base-address.test.ts
new file mode 100644
index 00000000000..ebf256cdd13
--- /dev/null
+++ b/packages/objectql/src/protocol-bundle-member-base-address.test.ts
@@ -0,0 +1,347 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#28802, ADR-0146 D2, #28390 ruling A] Member-grain coexistence on a base's
+ * address, for a bundle type (`ITEM_KEY_DISCRIMINATORS`: `email_template`,
+ * one item per `(name, locale)`).
+ *
+ * A base ships a template in `en-US` only. Its address — the read naming it,
+ * the read naming no package, the list scoped to it, the env-wide list and
+ * the executing list — serves the base's members, its pair's declared
+ * overrides, and its pair's own ADDED members (a locale the base does not
+ * ship). A dependent package's added member under the base's name is served
+ * only under that package's own address.
+ *
+ * Pinned through the real protocol over the real `ObjectQL` engine and an
+ * in-memory driver (the harness of
+ * `protocol-custom-override-effective-read.test.ts`), every package
+ * installed through the package door with its dependency declared, every row
+ * written through the draft save and the per-item publish.
+ *
+ * Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s
+ * `dist/`, so a change there is read here after that package is rebuilt.
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import type { ServiceObject } from '@objectstack/spec/data';
+import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol';
+import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
+import { ObjectQL } from './engine.js';
+
+const BASE = 'com.example.showcase';
+const PAIR = 'com.example.showcase.custom';
+const DEPENDENT = 'com.default.acme-addon';
+/** Shipped in `en-US` only. */
+const NAME = 'task_done_email';
+/** Shipped in `fr-FR` only: a bundle with no canonical member. */
+const FR_ONLY = 'task_late_email';
+const TYPE = 'email_template';
+/** A non-bundle type's item the base ships, for the control: its identity is its name alone. */
+const VIEW = 'showcase_task.default';
+const BASE_COLUMNS = ['title', 'status'];
+
+const sysMetadataObject: ServiceObject = {
+ name: 'sys_metadata',
+ label: 'System Metadata',
+ fields: {
+ id: { name: 'id', label: 'ID', type: 'text' as const },
+ type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
+ name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
+ organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
+ package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
+ bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 },
+ metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
+ draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const },
+ checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
+ state: { name: 'state', label: 'State', type: 'text' as const },
+ version: { name: 'version', label: 'Version', type: 'number' as const },
+ created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
+ updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
+ },
+};
+
+function makeMemoryDriver() {
+ const stores = new Map>>();
+ const storeFor = (obj: string) => {
+ let s = stores.get(obj);
+ if (!s) { s = new Map(); stores.set(obj, s); }
+ return s;
+ };
+ let nextId = 0;
+ const matchesWhere = (row: Record, where: any): boolean => {
+ if (!where || typeof where !== 'object') return true;
+ for (const [k, v] of Object.entries(where)) {
+ if (k === '$and' && Array.isArray(v)) {
+ if (!v.every((w: any) => matchesWhere(row, w))) return false;
+ continue;
+ }
+ if (k === '$or' && Array.isArray(v)) {
+ if (!v.some((w: any) => matchesWhere(row, w))) return false;
+ continue;
+ }
+ if (k.startsWith('$')) continue;
+ const rowVal = row[k];
+ if (v && typeof v === 'object' && Array.isArray((v as any).$in)) {
+ if (!(v as any).$in.includes(rowVal === undefined ? null : rowVal)) return false;
+ continue;
+ }
+ const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
+ const a = rowVal === undefined ? null : rowVal;
+ const b = expected === undefined ? null : expected;
+ if (a !== b) return false;
+ }
+ return true;
+ };
+ const driver: any = {
+ name: 'memory', version: '0.0.0', supports: {} as any,
+ async connect() {}, async disconnect() {}, async checkHealth() { return true; },
+ async execute() { return null; },
+ async find(object: string, ast: any) {
+ const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
+ return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
+ },
+ async findOne(object: string, ast: any) {
+ for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
+ return null;
+ },
+ async create(object: string, data: Record) {
+ nextId += 1;
+ const id = (data.id as string) ?? `r_${nextId}`;
+ const row = { ...data, id };
+ storeFor(object).set(id, row);
+ return row;
+ },
+ async update(object: string, id: string, data: Record) {
+ const s = storeFor(object);
+ const cur = s.get(id);
+ if (!cur) throw new Error(`not found: ${object}/${id}`);
+ const updated = { ...cur, ...data, id };
+ s.set(id, updated);
+ return updated;
+ },
+ async upsert(object: string, data: Record) {
+ const id = data.id as string | undefined;
+ if (id && storeFor(object).has(id)) return this.update(object, id, data);
+ return this.create(object, data);
+ },
+ async delete(object: string, id: string) { return storeFor(object).delete(id); },
+ async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
+ async bulkCreate(object: string, rows: Record[]) {
+ return Promise.all(rows.map((r) => this.create(object, r)));
+ },
+ async bulkUpdate() { return []; }, async bulkDelete() {},
+ async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
+ async commit() {}, async rollback() {},
+ };
+ return { driver, stores };
+}
+
+const template = (name: string, locale: string, subject: string) => ({
+ name, locale, label: name, subject, bodyHtml: `${subject}
`, bodyText: `${subject} body`, active: true,
+});
+
+function baseManifest(): Record {
+ return {
+ id: BASE,
+ name: 'showcase',
+ version: '1.0.0',
+ namespace: 'showcase',
+ emailTemplates: [template(NAME, 'en-US', 'Base EN'), template(FR_ONLY, 'fr-FR', 'Base late FR')],
+ objects: [{
+ name: 'showcase_task',
+ label: 'Task',
+ sharingModel: 'private',
+ fields: Object.fromEntries(BASE_COLUMNS.map((f) => [f, { name: f, label: f, type: 'text' }])),
+ }],
+ views: [{
+ object: 'showcase_task',
+ list: {
+ label: 'All Tasks',
+ type: 'grid',
+ data: { provider: 'object', object: 'showcase_task' },
+ columns: BASE_COLUMNS.map((field) => ({ field })),
+ },
+ }],
+ };
+}
+
+async function boot() {
+ const engine = new ObjectQL();
+ const { driver } = makeMemoryDriver();
+ engine.registerDriver(driver, true);
+ await engine.init();
+ engine.registry.registerObject(sysMetadataObject, 'test-package');
+ for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
+ engine.registry.registerObject(o as any, 'test-package');
+ }
+ engine.registerApp(baseManifest());
+ const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
+ return { engine, protocol };
+}
+
+const savedWritable = process.env.OS_METADATA_WRITABLE;
+beforeEach(() => {
+ delete process.env.OS_METADATA_WRITABLE;
+ ObjectStackProtocolImplementation.resetEnvWritableCache();
+ resetEnvWritableMetadataTypes();
+});
+afterEach(() => {
+ if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE;
+ else process.env.OS_METADATA_WRITABLE = savedWritable;
+ ObjectStackProtocolImplementation.resetEnvWritableCache();
+ resetEnvWritableMetadataTypes();
+});
+
+/** Installs a writable package through the package door (the pair as its ensure door writes it). */
+async function install(protocol: ObjectStackProtocolImplementation, id: string): Promise {
+ await (protocol as any).installPackage({
+ manifest: {
+ id, name: id.split('.').join('-'), version: '0.1.0', type: 'app',
+ ...(id === PAIR
+ ? { namespace: 'showcase_custom', dependencies: { [BASE]: '^1' } }
+ : { dependencies: { [BASE]: '*' } }),
+ },
+ });
+}
+
+/** A draft save naming the package, then the per-item publish naming it and the member. */
+async function publishInto(
+ protocol: ObjectStackProtocolImplementation, packageId: string, name: string, locale: string, subject: string,
+ extra: Record = {},
+): Promise {
+ const item = { ...template(name, locale, subject), ...extra };
+ await protocol.saveMetaItem({ type: TYPE, name, item, mode: 'draft', packageId } as any);
+ await protocol.publishMetaItem({ type: TYPE, name, packageId, member: locale } as any);
+}
+
+/** The by-name read: `[subject, locale, _packageId]`, or `404` when nothing is served. */
+const byName = async (protocol: ObjectStackProtocolImplementation, name: string, member?: string, packageId?: string) => {
+ try {
+ const res: any = await protocol.getMetaItem({ type: TYPE, name, ...(member ? { member } : {}), ...(packageId ? { packageId } : {}) });
+ const item = res?.item;
+ if (item === undefined || item === null) return 404;
+ return [item.subject, item.locale, item._packageId];
+ } catch (error: any) {
+ if (error?.status === 404) return 404;
+ throw error;
+ }
+};
+const rowsOf = (items: any[], name: string) => items
+ .filter((i) => i?.name === name)
+ .map((i) => [i.locale, i._packageId, i.subject])
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0])));
+/** A list's members of `name`, as `[locale, _packageId, subject]` by locale. */
+const listed = async (protocol: ObjectStackProtocolImplementation, name: string, packageId?: string) =>
+ rowsOf(((await protocol.getMetaItems({ type: TYPE, ...(packageId ? { packageId } : {}) })) as any).items, name);
+const executing = async (protocol: ObjectStackProtocolImplementation, name: string) =>
+ rowsOf(((await protocol.getMetaItemsForExecution({ type: TYPE })) as any).items, name);
+
+/** The control's view items of a list, as `[_packageId, columns]`. */
+const listedViews = async (protocol: ObjectStackProtocolImplementation, packageId?: string) =>
+ (((await protocol.getMetaItems({ type: 'view', ...(packageId ? { packageId } : {}) })) as any).items as any[])
+ .filter((i) => i?.name === VIEW)
+ .map((i) => [i._packageId, (i?.config?.columns ?? i?.columns ?? []).map((c: any) => c?.field ?? c)]);
+
+const BASE_EN = ['en-US', BASE, 'Base EN'];
+const PAIR_FR = ['fr-FR', PAIR, 'Pair FR'];
+
+describe('[#28802] a bundle member on the base\'s address, at member grain', () => {
+ it('the pair\'s own added member is served by the by-name reads and listed by the base\'s lists and the executing list', async () => {
+ const { protocol } = await boot();
+ await install(protocol, PAIR);
+ await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR');
+
+ expect(await byName(protocol, NAME, 'fr-FR', BASE)).toEqual(['Pair FR', 'fr-FR', PAIR]);
+ expect(await byName(protocol, NAME, 'fr-FR')).toEqual(['Pair FR', 'fr-FR', PAIR]);
+ expect(await listed(protocol, NAME)).toEqual([BASE_EN, PAIR_FR]);
+ expect(await listed(protocol, NAME, BASE)).toEqual([BASE_EN, PAIR_FR]);
+ expect(await executing(protocol, NAME)).toEqual([BASE_EN, PAIR_FR]);
+ // Unchanged: the pair's own address, and the base's member on the base's address.
+ expect(await listed(protocol, NAME, PAIR)).toEqual([PAIR_FR]);
+ expect(await byName(protocol, NAME, 'fr-FR', PAIR)).toEqual(['Pair FR', 'fr-FR', PAIR]);
+ expect(await byName(protocol, NAME, 'en-US', BASE)).toEqual(['Base EN', 'en-US', BASE]);
+ });
+
+ it('a dependent\'s added member is served under the dependent only; the base\'s address answers 404 for it', async () => {
+ const { protocol } = await boot();
+ await install(protocol, DEPENDENT);
+ await publishInto(protocol, DEPENDENT, NAME, 'fr-FR', 'Dependent FR');
+
+ expect(await byName(protocol, NAME, 'fr-FR', DEPENDENT)).toEqual(['Dependent FR', 'fr-FR', DEPENDENT]);
+ expect(await listed(protocol, NAME, DEPENDENT)).toEqual([['fr-FR', DEPENDENT, 'Dependent FR']]);
+ expect(await byName(protocol, NAME, 'fr-FR', BASE)).toBe(404);
+ expect(await byName(protocol, NAME, 'fr-FR')).toBe(404);
+ expect(await listed(protocol, NAME)).toEqual([BASE_EN]);
+ expect(await listed(protocol, NAME, BASE)).toEqual([BASE_EN]);
+ expect(await executing(protocol, NAME)).toEqual([BASE_EN]);
+ });
+
+ it('with a dependent\'s member at the same locale, the base\'s address still serves 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 byName(protocol, NAME, 'fr-FR', BASE)).toEqual(['Pair FR', 'fr-FR', PAIR]);
+ expect(await byName(protocol, NAME, 'fr-FR')).toEqual(['Pair FR', 'fr-FR', PAIR]);
+ expect(await byName(protocol, NAME, 'fr-FR', DEPENDENT)).toEqual(['Dependent FR', 'fr-FR', DEPENDENT]);
+ expect(await listed(protocol, NAME)).toEqual([BASE_EN, PAIR_FR]);
+ expect(await executing(protocol, NAME)).toEqual([BASE_EN, PAIR_FR]);
+ });
+
+ it('a read naming no member of a bundle with no canonical member serves the base\'s member, not a dependent\'s canonical one', async () => {
+ const { protocol } = await boot();
+ await install(protocol, DEPENDENT);
+ await publishInto(protocol, DEPENDENT, FR_ONLY, 'en-US', 'Dependent late EN');
+
+ expect(await byName(protocol, FR_ONLY, undefined, BASE)).toEqual(['Base late FR', 'fr-FR', BASE]);
+ expect(await byName(protocol, FR_ONLY)).toEqual(['Base late FR', 'fr-FR', BASE]);
+ expect(await byName(protocol, FR_ONLY, 'en-US', BASE)).toBe(404);
+ expect(await byName(protocol, FR_ONLY, 'en-US', DEPENDENT)).toEqual(['Dependent late EN', 'en-US', DEPENDENT]);
+ });
+
+ it('control: a non-bundle type keeps name grain: a pair\'s and a dependent\'s same-named view stay out of the base\'s lists', async () => {
+ const { protocol } = await boot();
+ await install(protocol, PAIR);
+ await install(protocol, DEPENDENT);
+ // Rows the override write gate refuses today (an undeclared pair row, a
+ // dependent's row under the name the base ships), laid down as stored before it.
+ for (const [packageId, columns] of [[PAIR, ['title']], [DEPENDENT, ['status']]] as const) {
+ const item = {
+ name: VIEW, object: 'showcase_task', viewKind: 'list', label: 'All Tasks',
+ config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: columns.map((field) => ({ field })) },
+ };
+ const gated = protocol as any;
+ gated.overrideWriteVerdict = () => ({ verdict: 'not_concerned' });
+ try {
+ await protocol.saveMetaItem({ type: 'view', name: VIEW, item, mode: 'draft', packageId } as any);
+ await protocol.publishMetaItem({ type: 'view', name: VIEW, packageId } as any);
+ } finally {
+ delete gated.overrideWriteVerdict;
+ }
+ }
+ // The rows reach the list read: the per-package layer carries all three bodies.
+ const perPackage = (((await (protocol as any).getMetaItemsPerPackage({ type: 'view' })) as any).items as any[])
+ .filter((i) => i?.name === VIEW)
+ .map((i) => (i?.config?.columns ?? []).map((c: any) => c?.field ?? c).join(','))
+ .sort();
+ expect(perPackage).toEqual(['status', 'title', 'title,status']);
+ expect(await listedViews(protocol)).toEqual([[BASE, BASE_COLUMNS]]);
+ expect(await listedViews(protocol, BASE)).toEqual([[BASE, BASE_COLUMNS]]);
+ // Beside it, the bundle's members on the same reads are untouched by the view rows.
+ expect(await listed(protocol, NAME)).toEqual([BASE_EN]);
+ });
+
+ it('control: the pair\'s declared override of the base\'s member is served as the base\'s; the pair\'s own member beside it too', 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' } });
+ await publishInto(protocol, PAIR, NAME, 'fr-FR', 'Pair FR');
+
+ expect(await byName(protocol, NAME, 'en-US', BASE)).toEqual(['Override EN', 'en-US', BASE]);
+ expect(await byName(protocol, NAME, 'en-US')).toEqual(['Override EN', 'en-US', BASE]);
+ expect(await listed(protocol, NAME)).toEqual([['en-US', BASE, 'Override EN'], PAIR_FR]);
+ expect(await listed(protocol, NAME, BASE)).toEqual([['en-US', BASE, 'Override EN'], PAIR_FR]);
+ expect(await executing(protocol, NAME)).toEqual([['en-US', BASE, 'Override EN'], PAIR_FR]);
+ });
+});
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 c18399a41ea..7102ad980ea 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
@@ -1,42 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
-// [#28779, ADR-0146 D2 / D8, #28390] SHAPE PIN — documents what the reads
-// serve TODAY when a dependent package (not the base's `.custom` pair) ships
-// an email template under a base template's name at a locale the base does
-// not ship. Over the real showcase composition and its HTTP doors.
-//
-// ⛔ This file records a measured shape; it does not assert the shape is
-// right. Whether a dependent's member may be served on the base's address is
-// open (#28779 hand-back, evidence on #28390). When that is decided, the
-// cases marked SHAPE below move with the ruling — that is their purpose.
+// [#28802, ADR-0146 D2 / D8, #28390 ruling A] An email template locale the
+// base does not ship, added under the base template's name: the pair's own
+// addition is served and sent on the base's address; a dependent package's
+// stays under its own package. Over the real showcase composition and its
+// HTTP doors.
//
// ## The rig
//
-// The showcase ships `showcase_task_done_email` in `en-US` only. A Studio-made
-// package `com.example.showcaseaddon` declares the showcase as a dependency
-// and publishes the same name at `fr-FR`. Since PR #28777 the write gate
-// judges an email template by `(name, locale)`, so this is admitted as an
-// addition (the dependent's `en-US` at the same name is still refused).
+// The showcase ships `showcase_task_done_email` in `en-US` only. Since PR
+// #28777 the write gate judges an email template by `(name, locale)`, so a
+// `fr-FR` under that name is admitted as an addition, both from the base's
+// `.custom` pair (no `overrides`) and from a Studio-made package
+// `com.example.showcaseaddon` that declares the showcase as a dependency (its
+// `en-US` at the same name is still refused).
//
-// ## What reads today (measured on main 8f294e04)
+// ## What each describe pins (member-grain coexistence, #28779 option A)
//
-// - the read naming the dependent serves its `fr-FR`, stamped with its own
-// package id — as expected;
-// - SHAPE: the read naming the BASE (`?package=&member=fr-FR`) and the
-// package-less read (`?member=fr-FR`) also serve the dependent's body,
-// stamped `_packageId: `, `_provenance: 'org'`. The address
-// finds no row of the base, its pair or no package at `fr-FR`, and the
-// registry's by-name read falls to its cross-package tier
-// (`SchemaRegistry.getItem`, ADR-0048 §3.3 first composite match);
-// - SHAPE: the env-wide list, the list scoped to the base and the executing
-// list (`getMetaItemsForExecution`) leave the `fr-FR` out — the env-wide
-// narrowing keeps a name one code package ships to that package's items,
-// at NAME grain — so a send in `fr-FR` renders the base's `en-US`.
+// - the dependent's `fr-FR`: served by the read naming the dependent and
+// the list scoped to it; the read naming the base and the package-less
+// read answer 404 for it (they used to fall to the registry's
+// cross-package tier and serve it stamped with the dependent), and the
+// base's lists and the executing list leave it out, so a `fr-FR` send
+// renders the base's `en-US`;
+// - the pair's `fr-FR`: served on the base's address — the by-name reads,
+// the env-wide list, the list scoped to the base and the executing list —
+// stamped with the pair's own id, so `renderTemplate` in `fr-FR` renders
+// it (it used to be read by name but never listed, so never sent). The
+// list scoped to the pair is unchanged. With a dependent's `fr-FR` beside
+// it, the base's address still serves the pair's.
//
-// So the by-name reads and the lists disagree about the base's `fr-FR`, and
-// the body no read stamps as the base's is still answered for the base's
-// address. Controls: the base's own `en-US` reads as the base's on every
-// read, and the pair's declared override of `en-US` reads as the override.
+// Controls: the base's own `en-US` reads as the base's on every read, and the
+// pair's declared override of `en-US` reads as the override.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
@@ -49,6 +44,7 @@ const DEPENDENT = 'com.example.showcaseaddon';
const NAME = 'showcase_task_done_email';
const SHIPPED_SUBJECT = '✅ Task done: {{title}}';
const DEPENDENT_SUBJECT = 'Add-on FR: {{title}}';
+const PAIR_SUBJECT = 'Pair FR: {{title}}';
const OVERRIDE_SUBJECT = 'Override: {{title}}';
/** The refusal's code — the save door answers it beside a string `error`, an enveloped door inside `error`. */
const codeOf = (json: any): unknown => json?.code ?? json?.error?.code;
@@ -60,13 +56,15 @@ interface Served {
_packageId?: unknown;
}
-describe('[#28779] SHAPE: what the reads serve for a dependent\'s email template locale the base does not ship (showcase)', () => {
- let stack: VerifyStack | undefined;
- let token: string;
- let shipped: Record;
-
+/** One booted showcase and the reads both describes ask. */
+function showcaseRig() {
+ const rig = {
+ stack: undefined as VerifyStack | undefined,
+ token: '',
+ shipped: {} as Record,
+ };
const call = async (method: string, path: string, body?: unknown) => {
- const res = await stack!.apiAs(token, method, path, body);
+ const res = await rig.stack!.apiAs(rig.token, method, path, body);
return { status: res.status, json: (await res.json().catch(() => ({}))) as any };
};
/** `GET /meta/email_template/:name` at one member, with or without `?package=` — the served document, trimmed. */
@@ -77,114 +75,174 @@ describe('[#28779] SHAPE: what the reads serve for a dependent\'s email template
const item = (doc?.item ?? doc) as Record;
return { status: read.status, subject: item.subject, locale: item.locale, _packageId: item._packageId };
};
- /** The template's rows on `GET /meta/email_template[?package=]`, as `[locale, _packageId, subject]`. */
+ const rowsOf = (items: any[]) => items
+ .filter((i) => i?.name === NAME)
+ .map((i) => [i.locale, i._packageId, i.subject])
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0])));
+ /** The template's rows on `GET /meta/email_template[?package=]`, as `[locale, _packageId, subject]`, by locale. */
const listed = async (pkg?: string) => {
const list = await call('GET', `/meta/email_template${pkg ? `?package=${pkg}` : ''}`);
expect(list.status).toBe(200);
- const items: any[] = Array.isArray(list.json) ? list.json : (list.json?.items ?? list.json?.data?.items ?? list.json?.data ?? []);
- return items.filter((i) => i?.name === NAME).map((i) => [i.locale, i._packageId, i.subject]);
+ return rowsOf(Array.isArray(list.json) ? list.json : (list.json?.items ?? list.json?.data?.items ?? list.json?.data ?? []));
};
/** The executing list the email template loader reads (`protocol.getMetaItemsForExecution`). */
const executing = async () => {
- const protocol: any = await stack!.kernel.getServiceAsync('protocol');
+ const protocol: any = await rig.stack!.kernel.getServiceAsync('protocol');
const out = await protocol.getMetaItemsForExecution({ type: 'email_template' });
- return ((out?.items ?? []) as any[]).filter((i) => i?.name === NAME).map((i) => [i.locale, i._packageId, i.subject]);
+ return rowsOf(out?.items ?? []);
};
/** What a send in `locale` renders: `IEmailService.renderTemplate`, the resolver `sendTemplate` uses. */
const rendered = async (locale: string) => {
- const email: any = await stack!.kernel.getServiceAsync('email');
+ const email: any = await rig.stack!.kernel.getServiceAsync('email');
return (await email.renderTemplate({ template: NAME, locale, data: { title: 'T' } }))?.subject;
};
const body = (locale: string, subject: string, extra: Record = {}) => {
const out: Record = {};
- for (const [k, v] of Object.entries(shipped)) if (!k.startsWith('_')) out[k] = v;
+ for (const [k, v] of Object.entries(rig.shipped)) if (!k.startsWith('_')) out[k] = v;
return { ...out, locale, subject, ...extra };
};
const saveInto = (pkg: string, b: Record) =>
call('PUT', `/meta/email_template/${NAME}?mode=draft&package=${pkg}`, b);
const publishFrom = (pkg: string, member: string) =>
call('POST', `/meta/email_template/${NAME}/publish?package=${pkg}&member=${member}`, {});
-
- const BASE_EN = { status: 200, subject: SHIPPED_SUBJECT, locale: 'en-US', _packageId: BASE };
- const DEPENDENT_FR = { status: 200, subject: DEPENDENT_SUBJECT, locale: 'fr-FR', _packageId: DEPENDENT };
-
- beforeAll(async () => {
- stack = await bootShowcase();
- token = await stack.signIn();
+ /** Saves and publishes one member into `pkg`, both answering 200. */
+ const publishInto = async (pkg: string, b: Record) => {
+ const saved = await saveInto(pkg, b);
+ expect(saved.status, JSON.stringify(saved.json)).toBe(200);
+ const published = await publishFrom(pkg, String(b.locale));
+ expect(published.status, JSON.stringify(published.json)).toBe(200);
+ };
+ const createDependent = async () => {
+ const created = await call('POST', '/packages', {
+ manifest: {
+ id: DEPENDENT, name: 'Showcase add-on', version: '0.1.0', type: 'app', namespace: 'scaddon',
+ dependencies: { [BASE]: '*' },
+ },
+ });
+ expect(created.status, JSON.stringify(created.json)).toBe(201);
+ };
+ const ensurePair = async () => {
+ const ensured = await call('POST', `/packages/${BASE}/ensure-customization`);
+ expect([200, 201], JSON.stringify(ensured.json)).toContain(ensured.status);
+ };
+ const boot = async () => {
+ rig.stack = await bootShowcase();
+ rig.token = await rig.stack.signIn();
const read = await call('GET', `/meta/email_template/${NAME}?member=en-US`);
expect(read.status).toBe(200);
const doc = read.json?.data ?? read.json?.item ?? read.json;
- shipped = doc?.item ?? doc;
- }, 180_000);
+ rig.shipped = doc?.item ?? doc;
+ };
+ return { rig, boot, byName, listed, executing, rendered, body, saveInto, publishInto, createDependent, ensurePair };
+}
- afterAll(async () => {
- await stack?.stop();
- });
+const BASE_EN = { status: 200, subject: SHIPPED_SUBJECT, locale: 'en-US', _packageId: BASE };
+const DEPENDENT_FR = { status: 200, subject: DEPENDENT_SUBJECT, locale: 'fr-FR', _packageId: DEPENDENT };
+const PAIR_FR = { status: 200, subject: PAIR_SUBJECT, locale: 'fr-FR', _packageId: PAIR };
+const BASE_EN_ROW = ['en-US', BASE, SHIPPED_SUBJECT];
+const PAIR_FR_ROW = ['fr-FR', PAIR, PAIR_SUBJECT];
+
+describe('[#28802] a dependent\'s email template locale the base does not ship stays under the dependent (showcase)', () => {
+ const r = showcaseRig();
+
+ beforeAll(async () => { await r.boot(); }, 180_000);
+ afterAll(async () => { await r.rig.stack?.stop(); });
it('baseline: nothing answers `fr-FR`, and the base\'s `en-US` reads as the base\'s', async () => {
- expect(await byName('fr-FR', BASE)).toEqual({ status: 404 });
- expect(await byName('fr-FR')).toEqual({ status: 404 });
- expect(await byName('en-US', BASE)).toEqual(BASE_EN);
- expect(await byName('en-US')).toEqual(BASE_EN);
- expect(await rendered('fr-FR')).toBe('✅ Task done: T');
+ expect(await r.byName('fr-FR', BASE)).toEqual({ status: 404 });
+ expect(await r.byName('fr-FR')).toEqual({ status: 404 });
+ expect(await r.byName('en-US', BASE)).toEqual(BASE_EN);
+ expect(await r.byName('en-US')).toEqual(BASE_EN);
+ expect(await r.rendered('fr-FR')).toBe('✅ Task done: T');
});
it('the dependent publishes `fr-FR` under the base\'s name, admitted as an addition; its `en-US` is refused', async () => {
- const created = await call('POST', '/packages', {
- manifest: {
- id: DEPENDENT, name: 'Showcase add-on', version: '0.1.0', type: 'app', namespace: 'scaddon',
- dependencies: { [BASE]: '*' },
- },
- });
- expect(created.status, JSON.stringify(created.json)).toBe(201);
-
- const saved = await saveInto(DEPENDENT, body('fr-FR', DEPENDENT_SUBJECT));
- expect(saved.status, JSON.stringify(saved.json)).toBe(200);
- const published = await publishFrom(DEPENDENT, 'fr-FR');
- expect(published.status, JSON.stringify(published.json)).toBe(200);
+ await r.createDependent();
+ await r.publishInto(DEPENDENT, r.body('fr-FR', DEPENDENT_SUBJECT));
// Control: the member the base ships is still a same-name collision.
- const refused = await saveInto(DEPENDENT, body('en-US', 'Add-on EN: {{title}}'));
+ const refused = await r.saveInto(DEPENDENT, r.body('en-US', 'Add-on EN: {{title}}'));
expect(refused.status, JSON.stringify(refused.json)).toBe(403);
expect(codeOf(refused.json)).toBe('NOT_OVERRIDABLE');
});
it('the read naming the dependent serves its `fr-FR` under its own package id', async () => {
- expect(await byName('fr-FR', DEPENDENT)).toEqual(DEPENDENT_FR);
- expect(await listed(DEPENDENT)).toEqual([['fr-FR', DEPENDENT, DEPENDENT_SUBJECT]]);
+ expect(await r.byName('fr-FR', DEPENDENT)).toEqual(DEPENDENT_FR);
+ expect(await r.listed(DEPENDENT)).toEqual([['fr-FR', DEPENDENT, DEPENDENT_SUBJECT]]);
});
- it('SHAPE: the read naming the base and the package-less read serve the dependent\'s `fr-FR` body (stamped with the dependent)', async () => {
- expect(await byName('fr-FR', BASE)).toEqual(DEPENDENT_FR);
- expect(await byName('fr-FR')).toEqual(DEPENDENT_FR);
+ it('the read naming the base and the package-less read do not serve the dependent\'s `fr-FR`', async () => {
+ expect(await r.byName('fr-FR', BASE)).toEqual({ status: 404 });
+ expect(await r.byName('fr-FR')).toEqual({ status: 404 });
// Control: the base's own member is untouched on every read.
- expect(await byName('en-US', BASE)).toEqual(BASE_EN);
- expect(await byName('en-US')).toEqual(BASE_EN);
+ expect(await r.byName('en-US', BASE)).toEqual(BASE_EN);
+ expect(await r.byName('en-US')).toEqual(BASE_EN);
});
- it('SHAPE: the env-wide, base-scoped and executing lists leave `fr-FR` out, so a `fr-FR` send renders the base\'s `en-US`', async () => {
- expect(await listed()).toEqual([['en-US', BASE, SHIPPED_SUBJECT]]);
- expect(await listed(BASE)).toEqual([['en-US', BASE, SHIPPED_SUBJECT]]);
- expect(await executing()).toEqual([['en-US', BASE, SHIPPED_SUBJECT]]);
- expect(await rendered('fr-FR')).toBe('✅ Task done: T');
+ 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]);
+ expect(await r.executing()).toEqual([BASE_EN_ROW]);
+ expect(await r.rendered('fr-FR')).toBe('✅ Task done: T');
});
- it('control: the pair\'s declared override of `en-US` reads as the override; the dependent\'s `fr-FR` reads as before', async () => {
- const ensured = await call('POST', `/packages/${BASE}/ensure-customization`);
- expect([200, 201], JSON.stringify(ensured.json)).toContain(ensured.status);
- const saved = await saveInto(PAIR, body('en-US', OVERRIDE_SUBJECT, { overrides: { package: BASE, basedOn: '0.1.0' } }));
- expect(saved.status, JSON.stringify(saved.json)).toBe(200);
- const published = await publishFrom(PAIR, 'en-US');
- expect(published.status, JSON.stringify(published.json)).toBe(200);
+ it('control: the pair\'s declared override of `en-US` reads as the override; the dependent\'s `fr-FR` stays off the base\'s address', async () => {
+ await r.ensurePair();
+ await r.publishInto(PAIR, r.body('en-US', OVERRIDE_SUBJECT, { overrides: { package: BASE, basedOn: '0.1.0' } }));
const OVERRIDE_EN = { status: 200, subject: OVERRIDE_SUBJECT, locale: 'en-US', _packageId: BASE };
- expect(await byName('en-US', BASE)).toEqual(OVERRIDE_EN);
- expect(await byName('en-US')).toEqual(OVERRIDE_EN);
- expect(await listed()).toEqual([['en-US', BASE, OVERRIDE_SUBJECT]]);
- expect(await rendered('en-US')).toBe('Override: T');
-
- expect(await byName('fr-FR', BASE)).toEqual(DEPENDENT_FR);
- expect(await byName('fr-FR')).toEqual(DEPENDENT_FR);
- expect(await rendered('fr-FR')).toBe('Override: T');
+ expect(await r.byName('en-US', BASE)).toEqual(OVERRIDE_EN);
+ 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.byName('fr-FR', BASE)).toEqual({ status: 404 });
+ expect(await r.byName('fr-FR')).toEqual({ status: 404 });
+ expect(await r.byName('fr-FR', DEPENDENT)).toEqual(DEPENDENT_FR);
+ expect(await r.rendered('fr-FR')).toBe('Override: T');
+ });
+});
+
+describe('[#28802] the pair\'s own email template locale the base does not ship is served and sent on the base\'s address (showcase)', () => {
+ const r = showcaseRig();
+
+ beforeAll(async () => { await r.boot(); }, 180_000);
+ afterAll(async () => { await r.rig.stack?.stop(); });
+
+ it('the pair publishes `fr-FR` without `overrides`, admitted as its own member', async () => {
+ await r.ensurePair();
+ await r.publishInto(PAIR, r.body('fr-FR', PAIR_SUBJECT));
+ expect(await r.byName('fr-FR', PAIR)).toEqual(PAIR_FR);
+ expect(await r.listed(PAIR)).toEqual([PAIR_FR_ROW]);
+ });
+
+ it('the by-name reads naming the base and naming no package serve the pair\'s `fr-FR`; the base\'s `en-US` is untouched', async () => {
+ expect(await r.byName('fr-FR', BASE)).toEqual(PAIR_FR);
+ expect(await r.byName('fr-FR')).toEqual(PAIR_FR);
+ expect(await r.byName('en-US', BASE)).toEqual(BASE_EN);
+ expect(await r.byName('en-US')).toEqual(BASE_EN);
+ });
+
+ 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]);
+ expect(await r.executing()).toEqual([BASE_EN_ROW, PAIR_FR_ROW]);
+ expect(await r.rendered('fr-FR')).toBe('Pair FR: T');
+ expect(await r.rendered('en-US')).toBe('✅ Task done: T');
+ // The list scoped to the pair is unchanged.
+ expect(await r.listed(PAIR)).toEqual([PAIR_FR_ROW]);
+ });
+
+ it('a dependent\'s `fr-FR` beside it stays under the dependent; the base\'s address keeps serving the pair\'s', async () => {
+ await r.createDependent();
+ await r.publishInto(DEPENDENT, r.body('fr-FR', DEPENDENT_SUBJECT));
+
+ 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.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]);
+ expect(await r.rendered('fr-FR')).toBe('Pair FR: T');
});
});