From 455c1f140411d93292ea4c282321164724b2106f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:04:03 +0000 Subject: [PATCH 1/4] fix(metadata-protocol): serve a pair-added bundle member on the base's address, keep a dependent's under its own package The env-wide list narrowed a name one code package ships at name grain, so the pair's own added email template locale was read by name but never listed or executed, and the by-name reads on the base's address fell to the registry's cross-package tier and served a dependent's member under the base's name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw --- .../src/protocol-environment-rows.ts | 25 +- packages/metadata-protocol/src/protocol.ts | 105 ++++++- ...dent-unshipped-locale-read.dogfood.test.ts | 260 +++++++++++------- 3 files changed, 286 insertions(+), 104 deletions(-) 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/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'); }); }); From 02fa093736acc10a062e41301aa3ff296dca6f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:06:11 +0000 Subject: [PATCH 2/4] test(objectql): pin member-grain bundle reads on a base's address Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw --- ...rotocol-bundle-member-base-address.test.ts | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 packages/objectql/src/protocol-bundle-member-base-address.test.ts 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..5ed0917a0db --- /dev/null +++ b/packages/objectql/src/protocol-bundle-member-base-address.test.ts @@ -0,0 +1,291 @@ +// 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'; + +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')], + }; +} + +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); + +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: 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]); + }); +}); From f085057cbe729cc8ecf3e0db867990302cb9e3f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:28:55 +0000 Subject: [PATCH 3/4] chore(changeset): metadata-protocol member-grain bundle reads on a base's address Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw --- .changeset/28802-bundle-member-base-address.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/28802-bundle-member-base-address.md diff --git a/.changeset/28802-bundle-member-base-address.md b/.changeset/28802-bundle-member-base-address.md new file mode 100644 index 00000000000..ee43e42a2b3 --- /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.** A dependent package's added locale under a base template's name 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 the dependent's body. It is still served by the read naming the dependent 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. From 47b1c37574444ec65bc0a544281952f2d5ff26af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 22:31:36 +0000 Subject: [PATCH 4/4] test(objectql): a non-bundle control beside the member-grain cases; changeset names any other package's member Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw --- .../28802-bundle-member-base-address.md | 2 +- ...rotocol-bundle-member-base-address.test.ts | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.changeset/28802-bundle-member-base-address.md b/.changeset/28802-bundle-member-base-address.md index ee43e42a2b3..0c35d909c38 100644 --- a/.changeset/28802-bundle-member-base-address.md +++ b/.changeset/28802-bundle-member-base-address.md @@ -12,5 +12,5 @@ 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.** A dependent package's added locale under a base template's name 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 the dependent's body. It is still served by the read naming the dependent and the list scoped to it; the base's lists already left it out. +- **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/objectql/src/protocol-bundle-member-base-address.test.ts b/packages/objectql/src/protocol-bundle-member-base-address.test.ts index 5ed0917a0db..ebf256cdd13 100644 --- a/packages/objectql/src/protocol-bundle-member-base-address.test.ts +++ b/packages/objectql/src/protocol-bundle-member-base-address.test.ts @@ -35,6 +35,9 @@ 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', @@ -143,6 +146,21 @@ function baseManifest(): Record { 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 })), + }, + }], }; } @@ -217,6 +235,12 @@ const listed = async (protocol: ObjectStackProtocolImplementation, name: string, 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']; @@ -276,6 +300,38 @@ describe('[#28802] a bundle member on the base\'s address, at member grain', () 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);