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
19 changes: 19 additions & 0 deletions .changeset/23074-member-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/objectql': patch
---

A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code

Clause-②: no

An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version:

- the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact;
- `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version;
- the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code.

- `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged.
- `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before.

A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language.
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
why: 'the definition itself' },
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope',
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' },
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope',
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' },
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope',
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares; [#23074] at each item\'s own bundle member' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
Expand All @@ -94,8 +94,8 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
why: 'scoped to a named package, and kept only when it is that package\'s own' },
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined', noPackage: true, disposition: 'no',
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package' },
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package; [#23074] at the row\'s own bundle member' },
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
why: 'not called without a package, and kept only when it is the container\'s own package\'s' },
{ member: 'shippedViewContainerOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const SITES: ReadonlyArray<Site & {
+ 'and a row bound to another package is refused (D9.9, OBJECT_OVERLAY_PACKAGE_MISMATCH)',
},
{
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), \'name\' as any',
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), \'name\' as any',
disposition: 'yes, view only: skips a name another package ships',
path: 'the list\'s hydration and the write-through on an unscoped kernel; the boot\'s loadMetaFromDb on every kernel',
why: 'a stored row: a view row bound to a package is not registered where another package ships its name, and the '
Expand Down
63 changes: 56 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1777,8 +1777,31 @@ type ShippingPackages = {
* stands in for the overlay layer over `artifactItem` alone, as before: the
* registry's hydration and a previewed draft, neither of which is a read's
* lock answer.
*
* [#23074] An artifact lends its envelope only to a body of its OWN identity.
* For a type whose identity is a pair ({@link ITEM_KEY_DISCRIMINATORS}:
* `email_template`'s `(name, locale)`), that is the same bundle member, read
* by {@link bundleMemberOf}, the identity's one declaration; an artifact of
* another member is treated as no artifact at all. So an environment overlay
* of a member the package does not ship (a `zh-CN` template beside a shipped
* `en-US`) keeps the tenant authorship it was registered with, and is never
* read as packaged code: `isCodeArtifactBody` false, no `_packageId` /
* `_packageVersion` / `_provenance` stamp, no artifact `_lock*`. Grafted, it
* read as the bundle's own artifact of that member to the registry's artifact
* lookup and its overlay-entry retirement, and the read doors served it under
* the package's provenance. The shipped member's overlay is unchanged: each
* caller looks the artifact up at the body's own member
* (`lookupArtifactItem`'s `member`), so it still wears that member's
* artifact's envelope. A registry that ignores `member` can answer another
* member's artifact, and this check is what keeps that answer off the body.
* Every other type has no members, so `bundleMemberOf` is `undefined` on both
* sides and the merge is exactly what it was. ⛔ The lock family a read
* resolved (`itemLock`) is applied before this check and is not moved by it:
* a lock binds the NAME, at the read and at the `_lock` gate alike
* ({@link resolveItemLock} over every package that ships the name).
*/
function mergeArtifactProtection(
type: string,
item: unknown,
artifactItem: unknown,
itemLock?: ItemLock,
Expand All @@ -1788,6 +1811,7 @@ function mergeArtifactProtection(
if (artifactItem === undefined || artifactItem === null) return item;
const a = artifactItem as Record<string, unknown>;
if (typeof a !== 'object') return item;
if (bundleMemberOf(type, a) !== bundleMemberOf(type, item)) return item;
const out: Record<string, unknown> = { ...(item as Record<string, unknown>) };
if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') {
if (a._lock !== undefined) out._lock = a._lock;
Expand All @@ -1801,6 +1825,18 @@ function mergeArtifactProtection(
return out;
}

/**
* [#23074] The bundle member a body IS, for a type whose identity is a pair
* ({@link ITEM_KEY_DISCRIMINATORS}): `itemDiscriminator` under the type's
* canonical spelling, so the canonical member for a body that declares none.
* `undefined` for every other type, which has no members. The member
* {@link mergeArtifactProtection} matches an artifact against, and the one
* each of its callers looks the artifact up at.
*/
function bundleMemberOf(type: string, body: unknown): string | undefined {
return itemDiscriminator(canonicalMetaType(type), body);
}

/**
* [#22024, ADR-0048, ADR-0010 §3.3] The package whose artifact envelope
* ({@link mergeArtifactProtection}: `_packageId`, `_packageVersion`,
Expand Down Expand Up @@ -9650,7 +9686,9 @@ export class ObjectStackProtocolImplementation implements
// takes priority; else the item's own `_packageId`.) [#22024]
// The by-name read takes the same rule ({@link envelopePackageId}).
const itemPackageId = envelopePackageId(packageId, it);
const a = this.lookupArtifactItem(request.type, itemName, itemPackageId);
// [#23074] …and to the item's own bundle member, whose artifact
// alone may lend it an envelope ({@link mergeArtifactProtection}).
const a = this.lookupArtifactItem(request.type, itemName, itemPackageId, bundleMemberOf(request.type, it));
let itemLock: ItemLock | undefined;
if (typeof itemName === 'string' && (it as any)?._draft !== true) {
const address: ItemAddress = {
Expand All @@ -9671,7 +9709,7 @@ export class ObjectStackProtocolImplementation implements
// is the other exit a client reads field metadata from, and
// an overlay row wins over the (already-governed) registry
// entry in the merge above, so it carries the same lie.
governed.push(this.governServedObject(request.type, mergeArtifactProtection(it, a, itemLock)));
governed.push(this.governServedObject(request.type, mergeArtifactProtection(request.type, it, a, itemLock)));
}
return {
type: request.type,
Expand Down Expand Up @@ -10436,8 +10474,11 @@ export class ObjectStackProtocolImplementation implements
// serves. Before, a read naming no package took the first-registered
// package's envelope, so a name two packages ship answered one
// package's stored copy under the other package's `_packageId`.
// [#23074] …and at the served item's own bundle member, whose artifact
// alone may lend it an envelope ({@link mergeArtifactProtection}): a
// member the package does not ship is served as the environment's own.
const artifactItem = this.lookupArtifactItem(
request.type, request.name, envelopePackageId(request.packageId, item),
request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item),
);
// [#21738] The lock is the one item-lock resolution's, over the layers
// this read resolved from its address: [#21803] every installed
Expand All @@ -10458,7 +10499,7 @@ export class ObjectStackProtocolImplementation implements
request.type,
this.governServedObject(
request.type,
mergeArtifactProtection(item, artifactItem, itemLock),
mergeArtifactProtection(request.type, item, artifactItem, itemLock),
),
);
// ADR-0047 — list views additionally get reference-integrity
Expand Down Expand Up @@ -18698,7 +18739,15 @@ export class ObjectStackProtocolImplementation implements
this.hydrateExpandedViewItems(type, data, options, registry);
return true;
}
const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined);
// [#23074] The artifact of the row's OWN bundle member: an overlay of
// a member the package does not ship has none, so it registers as the
// tenant row it is ({@link mergeArtifactProtection}). It used to take
// the bundle's artifact canonical-first, and a `zh-CN` overlay beside
// a shipped `en-US` was registered wearing that envelope, which every
// registry reader then took for the package's own `zh-CN` artifact.
const artifact = this.lookupArtifactItem(
type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data),
);
// [#16702] Say what this row IS before the artifact envelope is grafted
// on top of it. Every body reaching this hydrator came out of a
// `sys_metadata` write and is therefore tenant-authored by definition
Expand All @@ -18720,7 +18769,7 @@ export class ObjectStackProtocolImplementation implements
// and its receipt rendered both as the package. Registered as the
// tenant row it is, it is reported as one and never armed.
const envelope = canonicalType === 'flow' ? undefined : artifact;
registry.registerItem(type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), 'name' as any);
registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any);
this.hydrateExpandedViewItems(type, data, options, registry);
return true;
}
Expand Down Expand Up @@ -18872,7 +18921,7 @@ export class ObjectStackProtocolImplementation implements
// where the item's own artifact exists, its `_provenance` still
// wins (ADR-0010 §3.3), as it does on the container.
const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item;
out.push(mergeArtifactProtection(authored, ownArtifact) as Record<string, unknown>);
out.push(mergeArtifactProtection(type, authored, ownArtifact) as Record<string, unknown>);
}
return out;
}
Expand Down
Loading
Loading