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
16 changes: 16 additions & 0 deletions .changeset/28802-bundle-member-base-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: an email template locale added in a `<base>.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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no write is refused: the reads of a base's address now decide an email template member by member (ADR-0146 D2, ruling A on #28390). A stored dependent row under a base template's name is not converted: it stays served under its own package, the address the read naming that package and the list scoped to it already serve. -->

**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.
25 changes: 24 additions & 1 deletion packages/metadata-protocol/src/protocol-environment-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
105 changes: 103 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, string | undefined>();
items = (items as any[]).filter((it) => {
const itemName = it?.name;
Expand All @@ -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);
});
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading