Skip to content

Commit 0c47e87

Browse files
feat(metadata): the layered read takes the bundle member selector (#26861)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB8p7MBsAnbgGywykCisiqcv)_ Fixes #26699 #26649 stays open: it keeps the ui half (the Setup row link, the Locale column, and the editor passing the member to its layered and draft reads), and stays `pm:blocked` on this PR. Clause-②: yes Metadata-protocol: added Skills-docs: none — no skill or hand-written doc teaches `/layers` or its query parameters (`git grep -n "/layers\|getMetaItemLayered\|META_ITEM_LAYERS" -- skills/ .claude/skills/` → 0 hits; the only hand-written docs naming `/layers` are `content/docs/permissions/system-context.mdx:192` and `content/docs/ui/apps.mdx:366`, which describe its read gate, not its parameters) ## 维护者速读 - **改了什么:** Setup 编辑页读的分层视图(`GET /meta/:type/:name/layers`)现在能按语言读邮件模板的某一种语言(`?member=zh-CN`):代码层、覆盖层、生效值都是那种语言的;不带参数仍是默认语言,行为不变。 - **为什么改:** 多语言邮件模板的第二种语言在 Setup 里打不开(#26649),按 #26649 的裁决 A,服务端这扇门先补上,ui 半边随后。 - **风险与回滚:** 只给一个闭集加一个参数(`package` → `package`、`member`),两条传输路径共用同一个判定;不存在的语言答 404,不会退回默认语言;回滚即 revert 本 PR。 - **先看:** `packages/metadata-protocol/src/protocol.ts` 的 `getMetaItemLayered`(代码层按成员收窄的那段);`packages/rest/src/meta-item-read-routes.ts` 的 `/layers` 入口(改问共享判定);`packages/rest/src/meta-item-read-gate.ts` 的 `META_ITEM_LAYERS_PARAMS`。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run src/environment-overlay-email-locale-restart.test.ts`,预期 35 个测试全绿,其中 5 个是本 PR 的分层读取用例。 - **需要你做的:** 无,席位落地(席位先做一次契约复审)。 ## What changes - **Spec** (`packages/spec/src/api/protocol.zod.ts`): `GetMetaItemLayeredRequestSchema` gains `member`, described by the same `BUNDLE_MEMBER_SELECTOR_DESCRIPTION` the get, delete, publish and history requests use. Generated: `content/docs/references/api/protocol.mdx` (one row) and `packages/spec/authorable-surface/api.json` (one key). - **Protocol** (`packages/metadata-protocol/src/protocol.ts`, `getMetaItemLayered`): takes `member`, read through `resolveBundleMemberSelector` exactly as `getMetaItem` reads it. Every layer is that member's: the MetadataService copy counts only when it is the addressed member; the artifact lookup asks for the member (`lookupArtifactItem(…, wantedMember)`); the registry is asked for a named member and an answer for another member is dropped; the package-less address and the served-row resolution get the member. A member no layer holds answers no layer, which both doors answer `404`. - **REST** (`packages/rest/src/meta-item-read-gate.ts`, `meta-item-read-routes.ts`, `rest-server.ts`): `META_ITEM_LAYERS_PARAMS` is `['package', 'member']`, and the `layers` door's arity list carries `member`. RestServer's `/layers` route now asks `metaItemQueryRefusal('layers', …)` (ruling 6138588561, letter B), as its item read and the runtime dispatcher's `/layers` already do, writing the nested `{ error: { code, message } }` its `refuseUnknownQueryParams` call wrote. `serveMetaItemLayered` spreads `metaMemberSelector(req.query)` into the request. - **Runtime** (`packages/runtime/src/domains/meta.ts`): the dispatcher's `/layers` hands `metaMemberSelector(query)` to `answerMetaLayered`, which spreads it into `getMetaItemLayered`. - **Changeset**: `.changeset/26699-layered-read-member.md`, `minor` on spec, metadata-protocol, rest and runtime, with both declaration lines. ## Tests - `packages/objectql/src/environment-overlay-email-locale-restart.test.ts` (real engine, real protocol), new `[#26699]` block: the layered read of `zh-CN` answers `code: 'PKG zh'`, `overlay: 'ADMIN zh'`; a member nobody stored answers its own code layer and no overlay; CONTROL the canonical read (absent and `en-US`) answers the canonical layers, and the whole answer for `member: 'en-US'` equals the bare one; `fr-FR` answers no layer; a member on `view` and a blank one are the located `400 VALIDATION_ERROR` (status and code asserted). A protocol over the same engine with a MetadataService double whose copy is `zh-CN`: the canonical read (absent and `en-US`) reports `code: 'PKG en'`, never the service's other member, and the `zh-CN` read reports the service copy (`SVC zh`). - `packages/runtime/src/domains/meta-item-query-decision.test.ts`, the `/layers` block on both transports: an unrecognised name is the same located 400 on both (`Supported parameters: member, package.`); a repeated `member` is the same refusal on both and the protocol is never called; `?member=zh-CN` reaches `getMetaItemLayered` with the package beside it on both, and no selector sends no `member` key; `fr-FR` is `404` on both; a member on `view` reaches the real protocol and comes out as `400 VALIDATION_ERROR` with its sentence on both. - Pins updated: `packages/rest/src/meta-item-query-decision.test.ts` (closed-set pin, the layers door now passes `member`, a repeated `member` refused), `packages/rest/src/rest-server-meta-door-closed-sets.test.ts` (`/layers` left the member-refusing list; its unknown-name refusal stays pinned), `packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts` (the layered code-layer lookup's recorded arguments now carry `wantedMember`). ## Acceptance notes - **The canonical read, one narrow change.** With no member named, the code layer's MetadataService copy now counts only when it is the canonical member, as `getMetaItem`'s does (#22877). For every type without members nothing moves. For an `email_template` whose service copy is a non-canonical member (the service keys by name, so it holds whichever member registered last), the canonical layered read used to report that member as `code`; it now falls to the canonical artifact, the body the by-name read serves. The MetadataService-double test in the objectql file pins this (ablated below). - **The deprecated `?layers=` spelling is unchanged.** It still refuses a `member` beside it, with the sentence pinned byte for byte ("Read one member with `?member=` alone."). That remedy could now also name `/layers?member=`; left as is to keep the pinned answer, and the spelling is on its way out. - **SDK.** `MetadataClient.layered()` lives in `ui/packages/data-objectstack` and takes `{ packageId }` only. The editor needs `member` there, and that is #26649's ui half per its ruling, so it is not touched here. - **Declarations.** Both lines are copied from the claim (6448343327). Two readings for the seat: the claim writes `Clause-②: yes` without an arm, while the change is a widening (`yes (widening)` would also be accurate); and the director's ruling record on #26649 (6444406836) spelled this card `Metadata-protocol: none`, since a REST query parameter is not something a metadata author writes, while the claim says `added`. ## Verification Readings at head `9db80b0d3` (main merged at `1a8b7ebef`) unless a line names an earlier head; on a shared container. <details><summary>Ablation: the layered overlay read without the member</summary> Mutation through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `09617268` → `663e2627`): drop `...memberOption(member)` from the layered read's `findServedOverlayRow` call. `@objectstack/metadata-protocol` rebuilt; `ablation-dist-preflight` found the marker in `dist/index.cjs` and `dist/index.js`. `environment-overlay-email-locale-restart.test.ts`: 2 failed / 32 passed (the `zh-CN` layers and the `fr-FR` absence: both got the canonical row). Restored (`git diff HEAD` empty, blob equal to HEAD), rebuilt, preflight `--absent` exit 0, then 34/34 passed. Taken at `c71460ec1`. </details> <details><summary>Ablation: the layered code layer without the member check on the MetadataService copy</summary> Mutation through `scripts/ablation-replace.mjs` (anchor 1 → 0): the layered read's `if (isWantedMember(fromService.data))` becomes an always-true test carrying the marker `ABLATION_26699`. `@objectstack/metadata-protocol` rebuilt; `ablation-dist-preflight` found the marker in `dist/`. `environment-overlay-email-locale-restart.test.ts`: 1 failed / 34 passed (the MetadataService-double test: expected `PKG en`, received `SVC zh`). Restored (blob equal to HEAD), rebuilt, preflight `--absent` exit 0 with a clean tree, then 35/35 passed. Taken at `5cb644cdc`. </details> <details><summary>Tests (under os-verify-lock)</summary> At `9db80b0d3`: - `@objectstack/rest` vitest: Test Files 333 passed (333), Tests 6792 passed / 344 skipped. - `@objectstack/runtime` vitest: Test Files 464 passed (464), Tests 8418 passed / 19 skipped. - `@objectstack/metadata-protocol` vitest: Test Files 270 passed / 4 skipped (274), Tests 21666 passed / 26 skipped. - `@objectstack/plugin-security` vitest: Test Files 231 passed (231), Tests 5032 passed / 45 skipped (it calls `getMetaItemLayered` at `packaged-permission-set-lock-gate.ts:83` and `security-catalog-echo.ts:194`, both without a member). - `@objectstack/objectql` `environment-overlay-email-locale-restart.test.ts`: 35 passed (35). - `typecheck` on spec, metadata-protocol, rest, runtime and objectql: VERDICT command-exit 0. - `@objectstack/spec check:generated`: all 14 generated artifacts up to date; the merge needed no regeneration. At `c71460ec1`: `@objectstack/spec` vitest, Test Files 814 passed (814), Tests 23320 passed / 1 todo; `@objectstack/plugin-security` vitest, 231 passed (231), Tests 5029 passed / 45 skipped (CI's Test Core (6/8) red on that head did not reproduce; see comment 6452915227). </details> <details><summary>Derived gates (dispatch-gates --commands, 109 families)</summary> Taken before the main merge at `1a8b7ebef`, on the branch's own changes. `node scripts/pm/dispatch-gates.mjs --ran FILE --repo objectstack-ai/objectstack`: 109 derived, 107 run, 2 NOT-MEASURED, 0 UNRUN; exit 0. - 107 families exit 0. - NOT MEASURED, exit 3 (PREREQUISITE NOT MET: they read a whole-repo build): `pnpm --filter @objectstack/spec run check:skill-examples`, `pnpm check:dual-build-cjs-loads`. CI runs both on the PR. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgNjQhS286u5WaoeU8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ce3404 commit 0c47e87

14 files changed

Lines changed: 311 additions & 53 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/rest': minor
5+
'@objectstack/runtime': minor
6+
---
7+
8+
feat(metadata): the layered read takes the bundle member selector, so the layers of one locale of an `email_template` can be read
9+
10+
Clause-②: yes
11+
Metadata-protocol: added
12+
13+
`GET /api/v1/meta/:type/:name/layers` and the protocol's `getMetaItemLayered` now accept `member`, the same bundle member selector the by-name read, delete, publish, history and revert already take. Before, the layered view only ever showed the canonical member (`en-US` for `email_template`) and refused `?member=` with 400, so an editor built on it could not open a second locale of a template.
14+
15+
- **Named the same way.** `?member=` on the REST route and `member` on `GetMetaItemLayeredRequest` (`@objectstack/spec`), spelled as the member's discriminator value (`zh-CN`). Absent, the canonical member's layers, as before; naming the canonical value is the same request.
16+
- **Every layer is that member's.** `code` is the package's artifact for that member, `overlay` the environment's stored row for that member, and `effective` what the by-name read with the same `member` serves. A member with nothing shipped and nothing stored answers `404`, the absent item, never the canonical member's layers.
17+
- **Refused as the item read refuses it.** A `member` on a type whose identity is its name alone (every type but `email_template` today), a blank one, or one over 100 characters is a `400 VALIDATION_ERROR` naming the selector. A repeated `?member=` is refused by the multiplicity rule.
18+
- **Both transports.** `RestServer`'s route and the runtime dispatcher's `/meta` catch-all judge the route's query through the one shared decision (`metaItemQueryRefusal('layers', …)` in `@objectstack/rest`), whose closed set for the route is now `package` and `member`. `META_ITEM_LAYERS_PARAMS` reads `['package', 'member']`.
19+
- **Unchanged.** The deprecated `?layers=` flag on the plain read still refuses a `member` beside it; read one member's layers on `/layers` instead.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,7 @@ This schema accepts one of the following structures:
13801380
| **type** | `string` | ✅ | Metadata type name |
13811381
| **name** | `string` | ✅ | Item name |
13821382
| **packageId** | `string` | optional | Optional package ID — scopes the `code` layer so a same-name collision resolves to the requested package's artifact (ADR-0048). |
1383+
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
13831384

13841385

13851386
---

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
101101
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
102102
{ member: 'readMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
103103
why: '[#22877] a bundled type\'s addressed member (the selector\'s, else the canonical one) when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' },
104-
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
105-
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' },
104+
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
105+
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer; [#26699] at the addressed member (the selector\'s, else the canonical one)' },
106106
{ member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no',
107107
why: 'the packaged base the translators compare a served body against: nothing of it is grafted onto the body' },
108108
{ member: 'readCodeLayerForCarryForward', args: 'type, name, pkg', noPackage: true, disposition: 'no',

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11400,6 +11400,8 @@ export class ObjectStackProtocolImplementation implements
1140011400
type: string;
1140111401
name: string;
1140211402
packageId?: string;
11403+
/** [#26699] The bundle member whose layers to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */
11404+
member?: string;
1140311405
}): Promise<{
1140411406
type: string;
1140511407
name: string;
@@ -11446,6 +11448,18 @@ export class ObjectStackProtocolImplementation implements
1144611448
if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) {
1144711449
throw objectPackageDisabledError(request.name);
1144811450
}
11451+
// [#26699] Ruling 6116165299 (Q2 i), extended to this read by the
11452+
// option-A ruling on #26649: ONE member of a bundled type, the
11453+
// selector's or else the canonical one, read exactly as
11454+
// {@link getMetaItem} reads it. Every layer below is that member's or
11455+
// nothing — the code layer's service copy, artifact and registry entry,
11456+
// and the stored row — so a named member nothing holds answers no
11457+
// layer, which the door answers as an absent item, ⛔ never with the
11458+
// canonical member's layers.
11459+
const member = resolveBundleMemberSelector(request.type, request.member);
11460+
const wantedMember = bundleMemberDiscriminator(request.type, member);
11461+
const isWantedMember = (candidate: unknown): boolean =>
11462+
wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember;
1144911463

1145011464
// [ADR-0131 D6] Environment → code: the overlay layer is the
1145111465
// environment's stored row; no organization's row is read.
@@ -11462,15 +11476,22 @@ export class ObjectStackProtocolImplementation implements
1146211476
request.packageId,
1146311477
);
1146411478
if (fromService.data !== undefined && fromService.data !== null) {
11479+
// [#26699] …when it is the member this read addresses, as in
11480+
// {@link getMetaItem}: the service keys a type's items by name
11481+
// alone, so for a bundle it holds whichever member it
11482+
// registered last. Another member's copy is no layer of this
11483+
// one; the artifact and registry lookups below ask for it.
1146511484
// [#7556] The CODE layer of an object is D9.6's "owner's
1146611485
// declaration with its extenders folded on", so the
1146711486
// MetadataService copy is its base, not the layer itself.
1146811487
// `effective` is `overlay ?? code`, so an object with no
1146911488
// overlay row — the ordinary shape — is corrected by this
1147011489
// single fold on both layers the diagnostic reports.
11471-
code = this.foldObjectExtendersFromRegistry(
11472-
request.type, request.name, fromService.data,
11473-
);
11490+
if (isWantedMember(fromService.data)) {
11491+
code = this.foldObjectExtendersFromRegistry(
11492+
request.type, request.name, fromService.data,
11493+
);
11494+
}
1147411495
} else if (fromService.degraded) {
1147511496
// [#5840] Kept, not swallowed — acted on after the registry
1147611497
// fallback below, which may still produce a real code layer.
@@ -11509,13 +11530,25 @@ export class ObjectStackProtocolImplementation implements
1150911530
// item-lock resolution below). A
1151011531
// runtime-registered item with no package carries no tenant marker
1151111532
// and keeps its code layer.
11533+
//
11534+
//
11535+
// [#26699] Each lookup is asked for the addressed member, exactly
11536+
// as {@link getMetaItem}'s are: the artifact at the member (the
11537+
// canonical one when none was named), and the registry for the
11538+
// member named. The registry's bare-name read still answers a
11539+
// bundle that ships no canonical member; a registry that does not
11540+
// narrow by member can answer another one for a NAMED member, and
11541+
// that answer is not this member's layer.
1151211542
const runtimeOnly = (item: unknown): unknown =>
1151311543
item !== undefined && isTenantAuthored(item) ? undefined : item;
11514-
let regItem = this.lookupArtifactItem(request.type, request.name, request.packageId)
11515-
?? runtimeOnly(this.engine.registry.getItem(request.type, request.name, request.packageId));
11544+
const namedMemberOnly = (item: unknown): unknown =>
11545+
member !== undefined && item !== undefined && !isWantedMember(item) ? undefined : item;
11546+
const artifact = this.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember);
11547+
let regItem = (artifact !== undefined && isWantedMember(artifact) ? artifact : undefined)
11548+
?? namedMemberOnly(runtimeOnly(this.engine.registry.getItem(request.type, request.name, request.packageId, member)));
1151611549
if (regItem === undefined) {
1151711550
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
11518-
if (alt) regItem = runtimeOnly(this.engine.registry.getItem(alt, request.name, request.packageId));
11551+
if (alt) regItem = namedMemberOnly(runtimeOnly(this.engine.registry.getItem(alt, request.name, request.packageId, member)));
1151911552
}
1152011553
if (regItem !== undefined) code = regItem;
1152111554
}
@@ -11558,15 +11591,17 @@ export class ObjectStackProtocolImplementation implements
1155811591
// lock stays at the request's address, as the `_lock` gate judges it.
1155911592
// Asked outside the `try` below: its own failed read already answers
1156011593
// the #5532 envelope, with the driver's error as `cause`.
11561-
const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name);
11594+
const contentPackageId = request.packageId || await this.packagelessReadAddress(request.type, request.name, member);
1156211595
try {
1156311596
// ADR-0048 prefer-local. [#21716] The ONE served-row resolution
1156411597
// {@link getMetaItem} calls too — see {@link findServedOverlayRow}.
11598+
// [#26699] At the addressed member, as that read asks it.
1156511599
const served = await this.findServedOverlayRow({
1156611600
type: request.type,
1156711601
name: request.name,
1156811602
state: 'active',
1156911603
...(contentPackageId ? { packageId: contentPackageId } : {}),
11604+
...memberOption(member),
1157011605
});
1157111606
overlayLockLayer = await this.overlayLockLayerAt({
1157211607
type: request.type,

‎packages/objectql/src/environment-overlay-email-locale-restart.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,74 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2)
497497
});
498498
});
499499

500+
// [#26699] The layered read (`getMetaItemLayered`, the Setup editor's read
501+
// through `GET /meta/:type/:name/layers`) takes the same selector: the option-A
502+
// ruling on #26649 extended ruling 6116165299's door list to it.
503+
const layersOf = async (protocol: ObjectStackProtocolImplementation, member?: string) => {
504+
const layered: any = await protocol.getMetaItemLayered({ type: T, name: NAME, ...(member !== undefined ? { member } : {}) });
505+
return {
506+
code: layered?.code?.subject ?? null,
507+
overlay: layered?.overlay?.subject ?? null,
508+
effective: layered?.effective?.subject ?? null,
509+
overlayScope: layered?.overlayScope ?? null,
510+
};
511+
};
512+
513+
describe('[#26699] the layered read addresses ONE member by the selector', () => {
514+
it('a non-canonical member answers its own code and overlay layers', async () => {
515+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
516+
const { protocol } = await bootWithBothSaved(stores);
517+
expect(await layersOf(protocol, 'zh-CN')).toEqual({
518+
code: 'PKG zh', overlay: 'ADMIN zh', effective: 'ADMIN zh', overlayScope: 'env',
519+
});
520+
// A member nobody stored: its own code layer, and no overlay — never the canonical row.
521+
const fresh = await boot(new Map());
522+
await fresh.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') });
523+
expect(await layersOf(fresh.protocol, 'zh-CN')).toEqual({
524+
code: 'PKG zh', overlay: null, effective: 'PKG zh', overlayScope: null,
525+
});
526+
});
527+
528+
it('CONTROL: the canonical read is unchanged — absent, and the canonical value, answer the canonical member', async () => {
529+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
530+
const { protocol } = await bootWithBothSaved(stores);
531+
const canonical = { code: 'PKG en', overlay: 'ADMIN en', effective: 'ADMIN en', overlayScope: 'env' };
532+
expect(await layersOf(protocol)).toEqual(canonical);
533+
expect(await layersOf(protocol, 'en-US')).toEqual(canonical);
534+
// The whole answer, not only the subjects: naming the canonical value is the same request.
535+
expect(await protocol.getMetaItemLayered({ type: T, name: NAME, member: 'en-US' }))
536+
.toEqual(await protocol.getMetaItemLayered({ type: T, name: NAME }));
537+
});
538+
539+
it('the MetadataService copy is a code layer only of the member it is — the canonical read never reports another member\'s', async () => {
540+
// The service keys a type's items by name alone, so for a bundle it
541+
// holds whichever member registered last: here, zh-CN.
542+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
543+
const { engine } = await bootWithBothSaved(stores);
544+
const metadata = { get: async () => template('zh-CN', 'SVC zh') };
545+
const withService = new ObjectStackProtocolImplementation(engine, () => new Map([['metadata', metadata]]), 'env_prod');
546+
expect((await layersOf(withService)).code).toBe('PKG en');
547+
expect((await layersOf(withService, 'en-US')).code).toBe('PKG en');
548+
expect((await layersOf(withService, 'zh-CN')).code).toBe('SVC zh');
549+
});
550+
551+
it('a member no layer holds answers no layer at all — the door\'s absent item, never the canonical member', async () => {
552+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
553+
const { protocol } = await bootWithBothSaved(stores);
554+
expect(await layersOf(protocol, 'fr-FR')).toEqual({ code: null, overlay: null, effective: null, overlayScope: null });
555+
});
556+
557+
it('a wrong selector is the located 400 of the one reading, and nothing is read', async () => {
558+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
559+
const { protocol } = await bootWithBothSaved(stores);
560+
await expectSelectorRefusal(
561+
protocol.getMetaItemLayered({ type: 'view', name: 'task_grid', member: 'zh-CN' }),
562+
/^The "member" selector names a bundle member, and a 'view' item has none/,
563+
);
564+
await expectSelectorRefusal(protocol.getMetaItemLayered({ type: T, name: NAME, member: ' ' }), /^The "member" selector is blank\./);
565+
});
566+
});
567+
500568
describe('the package-batch doors and the cluster fan-out carry the member (#22877, stage 2)', () => {
501569
/** A zh-CN draft bound to the package, beside an en-US active overlay that has none. */
502570
async function bootWithZhDraft(stores: Map<string, Map<string, Record<string, unknown>>>) {

‎packages/rest/src/meta-item-query-decision.test.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ const LAYERED_MEMBER = 'The "member" selector is not served by the layered view
3535
const refusal = (rule: string, message: string) => ({ status: 400, code: 'VALIDATION_ERROR', message, rule });
3636

3737
describe('[#23072] metaItemQueryRefusal — each door\'s closed set', () => {
38-
it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s three (no `force`)', () => {
38+
it('the measured sets: the item read\'s six, `/layers`\' two, `/published`\' none, and the dispatcher save\'s three (no `force`)', () => {
3939
expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']);
40-
expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']);
40+
// [#26699] The bundle member selector joined `/layers` (ruling 6116165299's
41+
// door list, extended to the layered read by the option-A ruling on #26649).
42+
expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package', 'member']);
4143
expect([...META_ITEM_PUBLISHED_PARAMS]).toEqual([]);
4244
// RestServer's own save door reads `force`; the dispatcher's does not.
4345
// [#24395] `surface` joined both save doors' sets.
@@ -76,11 +78,13 @@ describe('[#23072] metaItemQueryRefusal — each door\'s closed set', () => {
7678
});
7779
}
7880

79-
it('`member` is refused by every door that does not read it — the layered view, the published document and the body-addressed save', () => {
80-
for (const door of ['layers', 'published', 'save'] as const) {
81+
it('`member` is refused by every door that does not read it — the published document and the body-addressed save', () => {
82+
for (const door of ['published', 'save'] as const) {
8183
expect(metaItemQueryRefusal(door, { member: 'zh-CN' })?.rule, door).toBe('unknown-parameter');
8284
}
8385
expect(metaItemQueryRefusal('item', { member: 'zh-CN' })).toBeUndefined();
86+
// [#26699] The layered view reads it now.
87+
expect(metaItemQueryRefusal('layers', { member: 'zh-CN' })).toBeUndefined();
8488
});
8589
});
8690

@@ -92,6 +96,10 @@ describe('[#23072] metaItemQueryRefusal — arity, and the order of the three ru
9296
expect(metaItemQueryRefusal('layers', { package: ['a', 'b', 'c'] })).toEqual(refusal(
9397
'repeated-parameter', repeatedQueryParamMessage('package', 3),
9498
));
99+
// [#26699] …and the member the layered view now reads, once.
100+
expect(metaItemQueryRefusal('layers', { member: ['zh-CN', 'ja-JP'] })).toEqual(refusal(
101+
'repeated-parameter', repeatedQueryParamMessage('member', 2),
102+
));
95103
expect(metaItemQueryRefusal('save', { mode: ['draft', 'active'] })?.message)
96104
.toBe(repeatedQueryParamMessage('mode', 2));
97105
});

0 commit comments

Comments
 (0)