From e2787e8b643fcfd12d77c218bf717d8a76bad055 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:44:06 +0000 Subject: [PATCH 1/7] wip(metadata-protocol): the by-name /meta doors accept a bundle member selector (#22877, stage 2) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 438 +++++++++++++++--- .../src/sys-metadata-repository.ts | 22 + packages/objectql/src/registry.ts | 89 +++- packages/rest/src/rest-server.ts | 127 ++++- packages/spec/src/api/protocol.zod.ts | 19 + 5 files changed, 599 insertions(+), 96 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 85a9bc26d6a..9fe3f11a486 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -44,6 +44,8 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; import { backfillOverlayBundleMembers, ensureMetadataOverlayIndexes, + overlayMemberOfBody, + overlayMemberValue, overlayMemberWhere, } from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; @@ -1986,6 +1988,133 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin return itemDiscriminator(type, body); } +/** + * [#22877] The by-name doors' bundle member selector: the request member, and + * the `?member=` query parameter in each REST route's closed set (ruling + * 6116165299, Q2 i). Named after what the stored column holds + * (`sys_metadata.bundle_member`) and type-neutral, never `locale`: on `/meta`, + * `?locale=` is the response translation locale. + */ +export const BUNDLE_MEMBER_SELECTOR = 'member'; + +/** + * [#22877] The longest member a stored row can carry — the declared + * `maxLength` of `sys_metadata.bundle_member` (`@objectstack/metadata-core` + * `SysMetadataObject`, pinned equal in `protocol.bundle-member-selector.test.ts`). + * A longer selector names no member that could be stored, so it is refused as + * malformed rather than answered as an absent member. + */ +export const BUNDLE_MEMBER_MAX_LENGTH = 100; + +/** + * [#22877] THE one reading of the bundle member selector, shared by every + * by-name door (`getMetaItem`, `deleteMetaItem`, `publishMetaItem`, + * `historyMetaItem`, `rollbackMetaItem`). + * + * Returns the member as named, trimmed, or `undefined` when none was named, + * which every door reads as the CANONICAL member, as the registry's bare-name + * read does. Naming the canonical value (`en-US`) is the same request. A + * well-formed member with nothing stored or shipped is not refused here: each + * door answers it as it answers an absent item. + * + * Refuses, with a located 400 `VALIDATION_ERROR` (the code the REST door's + * recognition and arity refusals answer on the same routes, so one route + * keeps one dialect), a selector on a type whose identity is its name alone, + * and a value that cannot name a member: not a string, blank, or longer than + * {@link BUNDLE_MEMBER_MAX_LENGTH}. ⛔ A blank selector is never read as + * absent: `?member=` asked for something, and answering the canonical member + * for it would be a guess. + */ +export function resolveBundleMemberSelector(type: string, member: unknown): string | undefined { + if (member === undefined) return undefined; + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const disc = ITEM_KEY_DISCRIMINATORS[singular]; + const refuse = (sentence: string): never => { + const err = new Error(`The "${BUNDLE_MEMBER_SELECTOR}" selector ${sentence}`) as Error & { + code: 'VALIDATION_ERROR'; status: 400; param: string; + }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.param = BUNDLE_MEMBER_SELECTOR; + throw err; + }; + if (!disc) { + return refuse( + `names a bundle member, and a '${singular}' item has none: its identity is its name alone. ` + + `Remove the selector.`, + ); + } + const example = `'${disc.canonical}'`; + if (typeof member !== 'string') { + return refuse(`must be one string: the ${disc.field} of one '${singular}' member, e.g. ${example}.`); + } + const value = member.trim(); + if (value === '') { + return refuse( + `is blank. Name one '${singular}' member by its ${disc.field} (e.g. ${example}), or omit the ` + + `selector to address the canonical member (${example}).`, + ); + } + if (value.length > BUNDLE_MEMBER_MAX_LENGTH) { + return refuse( + `is ${value.length} characters long; a stored '${singular}' member holds at most ` + + `${BUNDLE_MEMBER_MAX_LENGTH}, so it names no member. Name one by its ${disc.field} (e.g. ${example}).`, + ); + } + return value; +} + +/** + * [#22877] The discriminator value of the member a selector addresses, for a + * registry call or a body comparison: the selector itself, or the canonical + * member when none was named. `undefined` for a type with no members. + */ +function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { + const disc = ITEM_KEY_DISCRIMINATORS[PLURAL_TO_SINGULAR[type] ?? type]; + if (!disc) return undefined; + return member?.trim() || disc.canonical; +} + +/** + * [#22877] `MetadataMutationEvent.member` for the member a write addressed, + * as a spread: empty for the canonical member and for a type with no members, + * so those events keep the shape they always had. + */ +function mutationEventMember(type: string, member: string | null | undefined): { member?: string } { + const value = overlayMemberValue(type, member); + return typeof value === 'string' ? { member: value } : {}; +} + +/** + * [#22877] One item of a package commit's revert plan (ADR-0067), as + * `sys_metadata_commit.items` stores it: `member` is the bundle member the + * item is, for a type whose identity is a pair, and is absent for the + * canonical member and every type with no members — so the plan of every + * other item is stored byte-identical to what it was. The commit timeline + * serves the declared four keys only ({@link ObjectStackProtocolImplementation.listCommits}). + */ +type CommitPlanItem = { type: string; name: string; existedBefore: boolean; prevVersion: number | null; member?: string }; + +/** [#22877] The identity of a plan item or a draft within one batch: two members of one name are two. */ +function commitPlanKey(item: { type: string; name: string; member?: string }): string { + return `${item.type}/${item.name}\u0000${item.member ?? ''}`; +} + +/** + * [#22877] A repository call's `member` option as a spread: present only when a + * member was named, so a call addressing the canonical member — and every call + * for a type with no members — passes exactly the options it always did. + */ +function memberOption(member: string | undefined): { member?: string } { + return member !== undefined ? { member } : {}; +} + +/** [#22877] {@link mutationEventMember} for the member a written BODY belongs to. */ +function mutationEventMemberOfBody(type: string, body: unknown): { member?: string } { + const value = overlayMemberOfBody(type, body); + return typeof value === 'string' ? { member: value } : {}; +} + /** * One place a stored `sys_metadata` row of an item can sit, as * {@link servedOverlayRowCandidates} orders them: a scope (the environment's, @@ -5254,6 +5383,14 @@ export interface MetadataMutationEvent { /** Resulting lifecycle state of the row the mutation produced. */ state: 'active' | 'draft' | 'deleted'; organizationId?: string | null; + /** + * [#22877] The bundle member the mutated row belongs to, for a type whose + * identity is a pair (`'zh-CN'` of an `email_template`) — part of the + * row's ADDRESS, so a peer converging on this signal re-reads that + * member's row, not the canonical one. Absent for the canonical member and + * for every type with no members, as on every by-name door. + */ + member?: string; } /** @@ -7012,12 +7149,18 @@ export class ObjectStackProtocolImplementation implements name: evt.name, org: 'env', } as Parameters[0]; - const current = await repo.get(ref, { state: 'active' }); + // [#22877] The member the event addresses: a peer's save of a second + // locale converges on THAT locale's row. Re-reading the canonical one, + // as this did, re-registered the canonical body and left the peer's + // registry without the member that changed (and a reset of it + // unhealed). A member the event names but this type does not have is + // read as the canonical one, as every repository read reads it. + const current = await repo.get(ref, { state: 'active', ...memberOption(evt.member) }); if (current) { // The row's OWN package binding, read from this replica's DB the // way the recovery callers derive it (#4867 / #4636) — the wire // carries no binding, and must not. - const packageId = await this.resolveOverlayPackageBinding(type, evt.name, orgId); + const packageId = await this.resolveOverlayPackageBinding(type, evt.name, orgId, evt.member); this.applyRegistryWriteThrough({ type, name: evt.name, @@ -7026,7 +7169,7 @@ export class ObjectStackProtocolImplementation implements organizationId: orgId, }); } else { - await this.restoreArtifactRegistryView(type, evt.name, orgId); + await this.restoreArtifactRegistryView(type, evt.name, orgId, evt.member); } // [#13609] Retire this replica's overlay-row cache at the moment of // convergence — never a direct `@objectstack/objectql` import, see @@ -9846,6 +9989,8 @@ export class ObjectStackProtocolImplementation implements name: string; state: 'active' | 'draft'; packageId?: string; + /** [#22877] The bundle member a by-name door's selector named; omitted, the canonical one. */ + member?: string; }): Promise<{ row: any } | undefined> { // [#21804] The order is {@link servedOverlayRowCandidates}, the one // the list's merge takes over the rows it read: the canonical @@ -9859,10 +10004,10 @@ export class ObjectStackProtocolImplementation implements if (type === undefined) continue; const where: Record = { type, name: args.name, state: args.state, organization_id: candidate.organizationId, - // [#22877] A by-name read of a bundled type answers its - // CANONICAL member, as the registry's bare-name read does; - // nothing for every other type. - ...overlayMemberWhere(type), + // [#22877] A by-name read of a bundled type answers the member + // its selector named, else its CANONICAL member, as the + // registry's bare-name read does; nothing for every other type. + ...overlayMemberWhere(type, args.member), }; if (candidate.packageId !== undefined) where.package_id = candidate.packageId; // [#21911, ADR-0096] The explicit system opt-in: a platform store @@ -9914,9 +10059,21 @@ export class ObjectStackProtocolImplementation implements }, options); } - async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { + async getMetaItem(request: { + type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, + /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string, + }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); + // [#22877] Ruling 6116165299 (Q2 i): ONE member of a bundled type is + // addressed by name, the selector's or else the canonical one. Every + // layer below answers that member or nothing — the stored row, the + // code layer's member and the registry's. + const member = resolveBundleMemberSelector(request.type, request.member); + const wantedMember = bundleMemberDiscriminator(request.type, member); + const isWantedMember = (candidate: unknown): boolean => + wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember; let item: unknown; // [ADR-0131 D6] Environment → code: the environment's stored row, // else the code packages' definition. No organization's row is read — @@ -9983,6 +10140,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'draft', ...(request.packageId ? { packageId: request.packageId } : {}), + ...memberOption(member), }))?.row; if (draftRec) { const draftItem = this.convertStoredItem( @@ -10028,6 +10186,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: readState, ...(request.packageId ? { packageId: request.packageId } : {}), + ...memberOption(member), }))?.row; storedRowServed = record !== undefined && record !== null; // [#20946, #21922] The stored-row half — see `storedRowDeclined` above. @@ -10074,7 +10233,9 @@ export class ObjectStackProtocolImplementation implements item: decorateMetadataItem(request.type, this.governServedObject(request.type, item)), // [#22114] The draft row's version token — the one a draft save // (`?mode=draft`) at this address accepts as `If-Match`. - version: await this.readVersionToken(request, 'draft'), + // [#22877] The member's own row's, as a save of that member's + // body addresses it. + version: await this.readVersionToken({ ...request, member }, 'draft'), }; } @@ -10164,12 +10325,18 @@ export class ObjectStackProtocolImplementation implements request.packageId, ); if (fromService.data !== undefined && fromService.data !== null) { + // [#22877] …when it is the member this read addresses. The + // service keys a type's items by name alone, so for a + // bundle it holds whichever member it registered last, and + // that one answered every member's read. // [#7556] A layer, not a resolved schema — see // {@link foldObjectExtendersFromRegistry}. No-op for every // type but `object`, and for an object nothing extends. - item = this.foldObjectExtendersFromRegistry( - request.type, request.name, fromService.data, - ); + if (isWantedMember(fromService.data)) { + item = this.foldObjectExtendersFromRegistry( + request.type, request.name, fromService.data, + ); + } } else if (fromService.degraded) { serviceDegraded = fromService; } @@ -10190,27 +10357,28 @@ export class ObjectStackProtocolImplementation implements // necessary. Without this, project-kernel callers of // `GET /api/v1/meta/object/` 404 even though the object is // registered and visible via the list endpoint. - // [#22877] A bundled type's bare name addresses its CANONICAL member - // (ruling A, Q2 i: no member named means the canonical one). Step 1 - // read only the canonical member's stored row; when there is none, the - // canonical member is the code layer's. The registry's bare-name read - // below answers its overlay tier first, which would hand back a - // non-canonical member's hydrated overlay (a template customized in - // `zh-CN` only) for a read that named no member. It still answers a - // bundle that ships no canonical member at all. - if (item === undefined) { - const disc = ITEM_KEY_DISCRIMINATORS[request.type]; - const artifact = disc ? this.lookupArtifactItem(request.type, request.name, request.packageId) : undefined; - if (disc && artifact !== undefined && itemDiscriminator(request.type, artifact) === disc.canonical) { - item = artifact; - } + // [#22877] A bundled type's name addresses ONE member (ruling A, Q2 i): + // the selector's, else the CANONICAL one. Step 1 read only that + // member's stored row; when there is none, the member is the code + // layer's — the artifact that ships it — and then the registry's, asked + // for that member. The registry's bare-name read answers the canonical + // member wherever it sits (its own [#22877] note), and still answers a + // bundle that ships no canonical member at all; a named member that no + // layer holds answers nothing, so the door answers it as absent. + if (item === undefined && wantedMember !== undefined) { + const artifact = this.lookupArtifactItem(request.type, request.name, request.packageId, wantedMember); + if (artifact !== undefined && isWantedMember(artifact)) item = artifact; } if (item === undefined) { - item = this.engine.registry.getItem(request.type, request.name, request.packageId); + item = this.engine.registry.getItem(request.type, request.name, request.packageId, member); if (item === undefined) { const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) item = this.engine.registry.getItem(alt, request.name, request.packageId); + if (alt) item = this.engine.registry.getItem(alt, request.name, request.packageId, member); } + // [#22877] A registry that does not narrow by member (a host + // double, an older registry) can answer another member of the + // bundle for a named one; that answer is not this member. + if (member !== undefined && item !== undefined && !isWantedMember(item)) item = undefined; // [#20946] The registry half — see `storedRowDeclined` above. // `getItem` answers the bare slot first, and for a shipped flow name // that slot holds the hydrated stored row, which is not one of the @@ -10355,7 +10523,7 @@ export class ObjectStackProtocolImplementation implements // editor reads `state: 'draft'` or the plain read. ...(request.previewDrafts ? {} - : { version: storedRowServed ? await this.readVersionToken(request, 'active') : null }), + : { version: storedRowServed ? await this.readVersionToken({ ...request, member }, 'active') : null }), }; } @@ -17216,7 +17384,18 @@ export class ObjectStackProtocolImplementation implements * type and its singular/plural twin. Returns `undefined` when the * registry is unavailable or the item is not artifact-backed. */ - private lookupArtifactItem(type: string, name: string, currentPackageId?: string): unknown { + private lookupArtifactItem( + type: string, + name: string, + currentPackageId?: string, + /** + * [#22877] For a type whose identity is a pair, the ONE bundle member + * whose artifact to answer, as its discriminator value; omitted, the + * bundle's artifact canonical-first. A registry without the parameter + * ignores it, so a caller that needs the member checks the answer. + */ + member?: string, + ): unknown { const registry = (this.engine as any)?.registry; if (!registry) return undefined; const singular = PLURAL_TO_SINGULAR[type] ?? type; @@ -17228,8 +17407,8 @@ export class ObjectStackProtocolImplementation implements // as unlocked after PUT+GET until restart). `currentPackageId` // (ADR-0048) makes that scan package-scoped (prefer-local). if (typeof registry.getArtifactItem === 'function') { - return registry.getArtifactItem(singular, name, currentPackageId) - ?? registry.getArtifactItem(type, name, currentPackageId); + return registry.getArtifactItem(singular, name, currentPackageId, member) + ?? registry.getArtifactItem(type, name, currentPackageId, member); } // Partial registry mocks in tests — fall back to getItem and apply // the same package-provenance filter inline. @@ -17981,7 +18160,8 @@ export class ObjectStackProtocolImplementation implements * (#11087) — the address the save resolves, not the key it was handed. */ private async readVersionToken( - request: { type: string; name: string; packageId?: string }, + /** [#22877] `member` — the bundle member the read addressed; omitted, the canonical one. */ + request: { type: string; name: string; packageId?: string; member?: string }, state: 'active' | 'draft', ): Promise { const repo = this.getOverlayRepo(); @@ -17990,7 +18170,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: 'env', } as Parameters[0]; - const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null); + const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null, request.member); return stored === null ? null : this.receiptVersion(stored); } @@ -19215,6 +19395,15 @@ export class ObjectStackProtocolImplementation implements name: string, /** The DELETE's own scope — `null` for an env-wide row. [#6780] */ organizationId: string | null, + /** + * [#22877] The bundle member whose row was removed, as the door's + * selector named it; omitted, the canonical member. For a type whose + * identity is a pair every tier below addresses that member alone: + * its shadow, its baseline and its overlay entry. The other members' + * rows are still stored and served, so their registry entries stay. + * No effect for a type with no members. + */ + member?: string, ): Promise { // [#6780] ADR-0005 — the plain-key entry belongs to the env-wide row, // so only an env-wide removal may heal (or retire) it. @@ -19222,6 +19411,10 @@ export class ObjectStackProtocolImplementation implements try { const registry: any = this.engine.registry; const singular = PLURAL_TO_SINGULAR[type] ?? type; + // The member's discriminator value (the canonical one when none was + // named), or `undefined` for a type with no members — whose registry + // calls below are then exactly the calls they always were. + const memberKey = bundleMemberDiscriminator(singular, member); // ── [ADR-0029 D9.7] THE OBJECT HALF'S LAYER SUBTRACTION ── // @@ -19248,15 +19441,20 @@ export class ObjectStackProtocolImplementation implements let healed = false; if (typeof registry.removeRuntimeShadow === 'function') { - healed = registry.removeRuntimeShadow(singular, name); + healed = registry.removeRuntimeShadow(singular, name, memberKey); if (type !== singular) { - healed = registry.removeRuntimeShadow(type, name) || healed; + healed = registry.removeRuntimeShadow(type, name, memberKey) || healed; } } if (healed) return; const baseline = await this.readItemFromMetadataService(type, name); - if (baseline.data !== undefined && baseline.data !== null) { + // [#22877] A baseline of ANOTHER member is no baseline of this one: + // the service keys a type's items by name alone, so it can hold a + // bundle's other member under the name. + const baselineServesMember = memberKey === undefined + || itemDiscriminator(singular, baseline.data) === memberKey; + if (baseline.data !== undefined && baseline.data !== null && baselineServesMember) { if (this.environmentId === undefined) { // [#22057] See tier 2 in the header. ⛔ View only. const bound = canonicalMetaType(type) === 'view' @@ -19275,8 +19473,8 @@ export class ObjectStackProtocolImplementation implements // [#5079] No artifact, no baseline: the row WAS the item. if (typeof registry.removeOverlayEntry === 'function') { - registry.removeOverlayEntry(singular, name); - if (type !== singular) registry.removeOverlayEntry(type, name); + registry.removeOverlayEntry(singular, name, memberKey); + if (type !== singular) registry.removeOverlayEntry(type, name, memberKey); } // [#6808] …and an `object` lives in a SECOND place, so tier 3 has a // second limb. `applyObjectRegistryMutation` writes both halves on @@ -19432,6 +19630,11 @@ export class ObjectStackProtocolImplementation implements type: string, name: string, organizationId: string | null, + /** + * [#22877] The bundle member whose row's binding to read; omitted, the + * canonical member. Nothing for a type with no members. + */ + member?: string, ): Promise { // [#21911] The explicit system opt-in — see findServedOverlayRow. const row = await this.engine.findOne('sys_metadata', { @@ -19440,6 +19643,7 @@ export class ObjectStackProtocolImplementation implements name, organization_id: organizationId, state: 'active', + ...overlayMemberWhere(type, member), }, context: { isSystem: true }, }); @@ -21035,6 +21239,8 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: mode === 'draft' ? 'draft' : 'active', organizationId: null, + // [#22877] The saved body's member — the row this save wrote. + ...mutationEventMemberOfBody(singularTypeForRepo, request.item), }); return { success: true, @@ -21652,6 +21858,8 @@ export class ObjectStackProtocolImplementation implements name: string; sinceSeq?: number; limit?: number; + /** [#22877] The bundle member whose lineage to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; }): Promise<{ events: import('@objectstack/metadata-core').MetadataEvent[] }> { // [#9157] #4432 / #7894 — CANONICAL TYPE KEY. See {@link canonicalMetaType} // and the twin comment on {@link auditMetaItem}; #9180 step ① routes all @@ -21685,6 +21893,12 @@ export class ObjectStackProtocolImplementation implements // (`viewes`) must be refused 400 naming `view` / `views`, not silently // absorbed by the early return. request = canonicalizeMetaRequestType(request); + // [#22877] Ruling 6116165299 (Q2 i) and the seat's answer 6118224899: + // the bundle shares ONE lineage counter per name, and this read serves + // one member's events of it — the selector's, else the canonical one. + // Checked before the gate below, so a selector on a type with no + // members is refused, never absorbed by the early empty answer. + const member = resolveBundleMemberSelector(request.type, request.member); // Canonical by construction from the fold above — NOT a second fold. const singularType = request.type; if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType) @@ -21714,7 +21928,7 @@ export class ObjectStackProtocolImplementation implements } as Parameters[0]; const events: import('@objectstack/metadata-core').MetadataEvent[] = []; - const opts: { sinceSeq?: number; limit?: number } = {}; + const opts: { sinceSeq?: number; limit?: number; member?: string } = { ...memberOption(member) }; if (request.sinceSeq !== undefined) opts.sinceSeq = request.sinceSeq; if (request.limit !== undefined) opts.limit = request.limit; // [#21207] Each event's hash and parent hash are served in keyed form — @@ -21789,6 +22003,8 @@ export class ObjectStackProtocolImplementation implements * multi-pass over the whole set), so it suppresses the per-item apply here. */ _skipSeedApply?: boolean; + /** [#22877] The bundle member whose draft to publish — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; }): Promise<{ success: boolean; version: string; @@ -21918,6 +22134,10 @@ export class ObjectStackProtocolImplementation implements // rewrites it on upgrade). Different input class, different map; see // {@link canonicalMetaType}'s header for why the two are not one fold. request = canonicalizeMetaRequestType(request); + // [#22877] Ruling 6116165299 (Q2 i): the promotion addresses ONE + // member's draft of a bundled type, the selector's, else the canonical + // one. Read once here and handed down trimmed. + const member = resolveBundleMemberSelector(request.type, request.member); // [commit 311433f6b] The item-name grammar verdict, same as `saveMetaItem`'s: // the promotion door writes an `active` row under this name, so an // off-grammar name is refused here too rather than promoted. With the @@ -21930,7 +22150,7 @@ export class ObjectStackProtocolImplementation implements // site has no transaction of its own, so recording it in the `catch` is // where it always effectively landed; what changed is that the helper no // longer assumes that on behalf of the batch route too. - const { singularType, advisories, result } = await this.promoteDraftForPublish(request) + const { singularType, advisories, result } = await this.promoteDraftForPublish({ ...request, member }) .catch(async (err: unknown) => { await this.recordPendingDenialAudit(err); throw err; @@ -22076,6 +22296,12 @@ export class ObjectStackProtocolImplementation implements * "same-batch" closure would be the item itself and change nothing. */ pending?: RuntimePendingDeclarations; + /** + * [#22877] The bundle member whose draft to promote; omitted, the + * canonical one. The draft read, the gate's read and the promotion + * all address it, so the body judged is the body that goes live. + */ + member?: string; }): Promise<{ singularType: string; /** @@ -22141,9 +22367,10 @@ export class ObjectStackProtocolImplementation implements draftRow = await this.engine.findOne('sys_metadata', { where: { type: singularType, name: request.name, organization_id: null, state: 'draft', - // [#22877] The canonical member's draft — the one - // `repo.get` / `repo.promoteDraft` below address. - ...overlayMemberWhere(singularType), + // [#22877] The addressed member's draft (the canonical + // one when none was named) — the one `repo.get` / + // `repo.promoteDraft` below address. + ...overlayMemberWhere(singularType, request.member), }, context: { isSystem: true }, }); @@ -22174,7 +22401,7 @@ export class ObjectStackProtocolImplementation implements } const draftForGate = await repo.get( { type: singularType, name: request.name, org: 'env' } as Parameters[0], - { state: 'draft', ...(draftKey !== undefined ? { packageId: draftKey } : {}) }, + { state: 'draft', ...(draftKey !== undefined ? { packageId: draftKey } : {}), ...memberOption(request.member) }, ); // [#21470] …and the divergent `name` refusal, on the same body and for // the same reason: a draft stored before `saveMetaItem` judged every @@ -22291,6 +22518,8 @@ export class ObjectStackProtocolImplementation implements // none), so a draft saved after the gate's read is refused as a // conflict instead of being promoted unjudged. expectedDraftHash: draftForGate ? draftForGate.hash : null, + // [#22877] …of the member that was judged. + ...memberOption(request.member), }); return { singularType, advisories: runtimeAdvisories, result }; } catch (err: any) { @@ -22453,6 +22682,8 @@ export class ObjectStackProtocolImplementation implements name: args.name, state: 'active', organizationId: null, + // [#22877] The promoted body's member — the row the promotion wrote. + ...mutationEventMemberOfBody(args.singularType, args.body), }); return out; } @@ -22581,7 +22812,10 @@ export class ObjectStackProtocolImplementation implements ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), ...(request?.packageId ? { packageId: request.packageId } : {}), }); - return { drafts }; + // [#22877] The declared header (`ListDraftsResponseSchema`): the + // repository's `member` is the package-batch doors' reading, not part of + // this list's contract, and is not served on it. + return { drafts: drafts.map(({ member: _member, ...header }) => header) }; } /** @@ -22645,7 +22879,7 @@ export class ObjectStackProtocolImplementation implements * and the absence of the member it needs is stated rather than assumed. */ private async collectBatchPendingDeclarations( - drafts: ReadonlyArray<{ type: string; name: string }>, + drafts: ReadonlyArray<{ type: string; name: string; member?: string }>, ): Promise { // [#13216] Typed as a mapped type over `RuntimePendingDeclarations` // with `-?`, not as a hand-listed literal. The literal it replaces was @@ -22706,7 +22940,8 @@ export class ObjectStackProtocolImplementation implements const ref = { type: singular, name: d.name, org: 'env', } as unknown as Parameters[0]; - const draft = await repo.get(ref, { state: 'draft' }); + // [#22877] The draft's own bundle member, as listed. + const draft = await repo.get(ref, { state: 'draft', ...memberOption(d.member) }); if (draft?.body === undefined || draft.body === null) continue; pending[key].push(draft.body); any = true; @@ -23141,15 +23376,21 @@ export class ObjectStackProtocolImplementation implements // unprovisioned is truthful emptiness, everything else surfaces. See // the `catch` below for why the benign branch keeps exactly the push // that was wrong unconditionally. - const commitItems: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> = []; + const commitItems: CommitPlanItem[] = []; for (const d of ordered) { try { // Read the pre-publish active row in the draft's scope, the // environment's (ADR-0131 D6), so an env-wide edit is not // mis-recorded as a create in the revert plan (#3115). // [#21911] The explicit system opt-in — see findServedOverlayRow. + // [#22877] …and the draft's own bundle member: another + // member's active row is not this member's pre-publish state, + // and the revert plan records the member it describes. const activeRow = (await this.engine.findOne('sys_metadata', { - where: { organization_id: null, type: d.type, name: d.name, state: 'active' }, + where: { + organization_id: null, type: d.type, name: d.name, state: 'active', + ...overlayMemberWhere(d.type, d.member), + }, context: { isSystem: true }, })) as { version?: number } | null; commitItems.push({ @@ -23157,6 +23398,7 @@ export class ObjectStackProtocolImplementation implements name: d.name, existedBefore: !!activeRow, prevVersion: activeRow && typeof activeRow.version === 'number' ? activeRow.version : null, + ...(d.member !== undefined ? { member: d.member } : {}), }); } catch (error) { // [#8896] Benign: `sys_metadata` has not been provisioned yet. @@ -23180,7 +23422,10 @@ export class ObjectStackProtocolImplementation implements // No new error code and no new response field: the caller // receives the read's own failure, envelope intact. if (!isMissingTableError(error, 'sys_metadata')) throw error; - commitItems.push({ type: d.type, name: d.name, existedBefore: false, prevVersion: null }); + commitItems.push({ + type: d.type, name: d.name, existedBefore: false, prevVersion: null, + ...(d.member !== undefined ? { member: d.member } : {}), + }); } } const publishedSeqs: number[] = []; @@ -23203,7 +23448,7 @@ export class ObjectStackProtocolImplementation implements // stubs) fall through to a plain sequential run with the same weaker // guarantee the repository's `withTxn` documents. type PromotedDraft = { - d: { type: string; name: string }; + d: { type: string; name: string; member?: string }; singularType: string; body: unknown; packageId: string | null; @@ -23238,7 +23483,7 @@ export class ObjectStackProtocolImplementation implements // deleted by the promote, and reading the draft is // unambiguous). const ref = { type: d.type, name: d.name, org: 'env' } as unknown as Parameters[0]; - const draft = await repo.get(ref, { state: 'draft' }); + const draft = await repo.get(ref, { state: 'draft', ...memberOption(d.member) }); if (draft?.body) seedBodies.push(draft.body); } const { singularType, advisories, result } = await this.promoteDraftForPublish({ @@ -23276,6 +23521,13 @@ export class ObjectStackProtocolImplementation implements // `d.packageId` is the row's own binding, so this // is the listed key restated, never a new one. packageId: d.packageId, + // [#22877] …and as the bundle member `listDrafts` + // surfaced it as: two members' drafts of one name + // are two drafts, and promoting the canonical one + // for each promoted it twice (`NO_DRAFT` the + // second time, aborting the batch) and left the + // other member's draft pending. + ...memberOption(d.member), // [#10377] The whole batch's own declarations, so // this member is judged against the package it is // being published AS PART OF and not against the @@ -23307,14 +23559,16 @@ export class ObjectStackProtocolImplementation implements // the published state land or roll back together, so a recorded // commit can never describe a partial publish. if (promoted.length > 0) { - const promotedKeys = new Set(promoted.map((p) => `${p.d.type}/${p.d.name}`)); + // [#22877] Keyed by member too: two members of one name + // are two plan items. + const promotedKeys = new Set(promoted.map((p) => commitPlanKey(p.d))); commit = await this.recordPackageCommit({ packageId: request.packageId, operation: 'apply', ...(request.message ? { message: request.message } : {}), ...(request.actor ? { actor: request.actor } : {}), ...(request.aiModel ? { aiModel: request.aiModel } : {}), - items: commitItems.filter((it) => promotedKeys.has(`${it.type}/${it.name}`)), + items: commitItems.filter((it) => promotedKeys.has(commitPlanKey(it))), ...(publishedSeqs.length ? { eventSeqStart: Math.min(...publishedSeqs), eventSeqEnd: Math.max(...publishedSeqs) } : {}), @@ -23327,7 +23581,7 @@ export class ObjectStackProtocolImplementation implements // BATCH_ABORTED so the caller sees the all-or-nothing semantics // instead of inferring them from publishedCount 0. const causal = e?.__batchItem as - { type: string; name: string; organizationId?: string | null } | undefined; + { type: string; name: string; organizationId?: string | null; member?: string } | undefined; // [#8333 · P6] `failed[].error` is RESPONSE DATA, not a message, so // no HTTP boundary's withhold reaches it. Measured on `origin/main` // by failing the `sys_metadata` write under the promote: the batch @@ -23442,7 +23696,7 @@ export class ObjectStackProtocolImplementation implements // for a failure nobody can attribute would put a fictional item in // a compliance ledger. `console.warn` above stays the record there. const failedOut = ordered.map((d) => - causal && d.type === causal.type && d.name === causal.name + causal && commitPlanKey(d) === commitPlanKey(causal) ? { type: d.type, name: d.name, error: clientFacingFailureText(e, 'publish failed'), @@ -23729,7 +23983,13 @@ export class ObjectStackProtocolImplementation implements * repository (ADR-0131 D6). */ private async discardDraftInItsScope( - draft: { type: string; name: string }, + /** + * [#22877] `member` — the draft's bundle member, as `listDrafts` + * surfaced it: the discard removes THAT member's draft. Without it the + * canonical member's was addressed for every draft of the name, so a + * second locale's draft answered "no pending draft" and stayed. + */ + draft: { type: string; name: string; member?: string }, actor: string | undefined, ): Promise { await this.deleteMetaItem({ @@ -23737,6 +23997,7 @@ export class ObjectStackProtocolImplementation implements name: draft.name, state: 'draft', ...(actor ? { actor } : {}), + ...(draft.member !== undefined ? { member: draft.member } : {}), }); } @@ -24972,7 +25233,7 @@ export class ObjectStackProtocolImplementation implements actor?: string; aiModel?: string; parentCommitId?: string; - items: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>; + items: CommitPlanItem[]; eventSeqStart?: number; eventSeqEnd?: number; }): Promise<{ commitId: string } | null> { @@ -25053,8 +25314,8 @@ export class ObjectStackProtocolImplementation implements private parseCommitItems( raw: unknown, - ): Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> { - if (Array.isArray(raw)) return raw as Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>; + ): CommitPlanItem[] { + if (Array.isArray(raw)) return raw as CommitPlanItem[]; if (typeof raw === 'string') { try { const p = JSON.parse(raw); @@ -25141,7 +25402,9 @@ export class ObjectStackProtocolImplementation implements ...(r.ai_model ? { aiModel: r.ai_model } : {}), ...(r.parent_commit_id ? { parentCommitId: r.parent_commit_id } : {}), itemCount: typeof r.item_count === 'number' ? r.item_count : 0, - items: this.parseCommitItems(r.items), + // [#22877] The declared item (`ListPackageCommitsResponseSchema`): + // a plan item's stored `member` is `revertCommit`'s reading. + items: this.parseCommitItems(r.items).map(({ member: _member, ...item }) => item), // [#14038] Canonicalise the ONE driver materialisation this // column is measured to produce (a valid JS `Date`, on // Postgres/MySQL); every other shape — including an @@ -25243,6 +25506,9 @@ export class ObjectStackProtocolImplementation implements const actor = request.actor ?? null; const reverted: Array<{ type: string; name: string; action: 'removed' | 'restored' }> = []; const failed: Array<{ type: string; name: string; error: string; code?: string }> = []; + // [#22877] The members of what `reverted` reports, in its order, for + // the revert's own plan below; the response keeps its declared keys. + const revertedMembers: Array = []; // Reverse apply order so artifacts that depend on others (e.g. a view on // a new object) are removed before the thing they reference. @@ -25375,8 +25641,13 @@ export class ObjectStackProtocolImplementation implements const itemOrgId = null; const repo = this.getOverlayRepo(); const ref = { type: it.type, name: it.name, org: itemOrgId ?? 'env' } as unknown as Parameters[0]; + // [#22877] The plan item's bundle member: every row read, write + // and heal below addresses it, so reverting a commit that changed + // one locale of a template leaves the other locale's row alone. + // Absent (the canonical member) on every other item, as before. + const member = it.member; try { - const current = await repo.get(ref, { state: 'active' }); + const current = await repo.get(ref, { state: 'active', ...memberOption(member) }); if (!it.existedBefore) { // Created by this commit → soft-remove (metadata only; table stays). // @@ -25419,6 +25690,7 @@ export class ObjectStackProtocolImplementation implements source: 'protocol.revertCommit', intent, state: 'active', + ...memberOption(member), }); } // [#6621] The registry must stop serving what the revert @@ -25476,7 +25748,7 @@ export class ObjectStackProtocolImplementation implements // #6602 gate this parameter carries asks "is this row // env-wide?", and an env-wide row reverted by an org-scoped // caller skipped the heal entirely while answering success. - await this.restoreArtifactRegistryView(it.type, it.name, itemOrgId); + await this.restoreArtifactRegistryView(it.type, it.name, itemOrgId, member); // [#14179] …and announce it on the ONE choke point, // {@link emitMetadataMutation}, exactly as the sibling // delete caller {@link deleteMetaItem} does after its own @@ -25508,9 +25780,11 @@ export class ObjectStackProtocolImplementation implements name: it.name, state: 'deleted', organizationId: itemOrgId, + ...mutationEventMember(it.type, member), }); } reverted.push({ type: it.type, name: it.name, action: 'removed' }); + revertedMembers.push(member); } else if (restoreToVersion !== null) { // Edited an existing artifact → restore the pre-commit body. // @@ -25555,7 +25829,7 @@ export class ObjectStackProtocolImplementation implements // fallible query downstream of a write that already succeeded — // the shape that ends in a `catch {}` swallowing a real outage // (#4867). Per ITEM, because a batch mixes bindings. - const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, itemOrgId); + const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, itemOrgId, member); const restored = await repo.restoreVersion(ref, restoreToVersion, { actor, source: 'protocol.revertCommit', @@ -25567,6 +25841,7 @@ export class ObjectStackProtocolImplementation implements // the SINGULAR type, the spelling the write-through // registers under. See {@link restoredBodyWriter}. deriveRestoredBody: this.restoredBodyWriter(PLURAL_TO_SINGULAR[it.type] ?? it.type, it.name), + ...memberOption(member), }); // [#6621] #4521 — a revert is a live write like any other: the // restored body must be the one the runtime dispatches on @@ -25626,8 +25901,10 @@ export class ObjectStackProtocolImplementation implements name: it.name, state: 'active', organizationId: itemOrgId, + ...mutationEventMember(it.type, member), }); reverted.push({ type: it.type, name: it.name, action: 'restored' }); + revertedMembers.push(member); } } catch (e: any) { // [#8333 · P11] `failed[].error` is revert-response DATA. @@ -25672,11 +25949,12 @@ export class ObjectStackProtocolImplementation implements message: `Revert: ${row.message ?? request.commitId}`, ...(request.actor ? { actor: request.actor } : {}), parentCommitId: request.commitId, - items: reverted.map((r) => ({ + items: reverted.map((r, index) => ({ type: r.type, name: r.name, existedBefore: r.action === 'restored', prevVersion: null, + ...(revertedMembers[index] !== undefined ? { member: revertedMembers[index] } : {}), })), }); @@ -25782,6 +26060,12 @@ export class ObjectStackProtocolImplementation implements toVersion: number; actor?: string; message?: string; + /** + * [#22877] The bundle member to revert — {@link resolveBundleMemberSelector}; + * omitted, the canonical one. The bundle shares one lineage counter, so + * a version of ANOTHER member answers 404 `VERSION_NOT_FOUND`. + */ + member?: string; }): Promise<{ success: boolean; version: string; @@ -25848,6 +26132,9 @@ export class ObjectStackProtocolImplementation implements // deleted because a reader arriving here still needs to know which of // these effects belong to THIS boundary and which moved to the producer. request = canonicalizeMetaRequestType(request); + // [#22877] Ruling 6116165299 (Q2 i): the revert addresses ONE member + // of a bundled type — the selector's, else the canonical one. + const member = resolveBundleMemberSelector(request.type, request.member); // Canonical by construction from the fold above. NOT a second fold: the // `PLURAL_TO_SINGULAR` lookup that used to stand here is exactly what // #7894 removed from this boundary. The binding survives under its own @@ -25901,7 +26188,7 @@ export class ObjectStackProtocolImplementation implements // it afterwards would put a fallible query downstream of a write that // already succeeded — the shape that ends in a `catch {}` swallowing a // real outage (#4867). - const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId); + const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId, member); try { const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. @@ -25914,6 +26201,8 @@ export class ObjectStackProtocolImplementation implements // credential move keeps the write-only channel's current // credential and stores none. See {@link restoredBodyWriter}. deriveRestoredBody: this.restoredBodyWriter(singularType, request.name), + // [#22877] The member whose lineage `toVersion` is taken from. + ...memberOption(member), }); // #4521 — a rollback is a live write like any other: the restored // body must be the one the runtime dispatches on immediately, not @@ -25979,6 +26268,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'active', organizationId: orgId, + ...mutationEventMember(singularType, member), }); return { success: true, @@ -26364,6 +26654,8 @@ export class ObjectStackProtocolImplementation implements * object" flow so a publish-to-preview leaves no orphan table. */ dropStorage?: boolean; + /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ + member?: string; }): Promise<{ success: boolean; message?: string; @@ -26383,6 +26675,10 @@ export class ObjectStackProtocolImplementation implements // spelling while the repository deletes under the singular — so a // DELETE could remove the row and leave the shadow it was meant to lift. request = canonicalizeMetaRequestType(request); + // [#22877] Ruling 6116165299 (Q2 i): a reset by name removes ONE + // member's row of a bundled type — the selector's, else the canonical + // one — and heals the registry for that member alone. + const member = resolveBundleMemberSelector(request.type, request.member); // [#21899] The removal verdict on an ORIGIN-GATED code-defined item // (ADR-0126 §3: `datasource` — code-defined read-only, runtime-created // free), held back until the row probe below. See @@ -26538,7 +26834,7 @@ export class ObjectStackProtocolImplementation implements const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; // Probe first — "no overlay exists" is a success/no-op, not // a conflict. The repo would otherwise throw ConflictError. - const current = await repo.get(ref, { state: targetState }); + const current = await repo.get(ref, { state: targetState, ...memberOption(member) }); // [#21899] An origin-gated code-defined item with NO stored row: // the delete would remove nothing, and the item itself is not // removable at runtime — refused with the package door's own @@ -26561,7 +26857,7 @@ export class ObjectStackProtocolImplementation implements // `sys_metadata`. A gate applied only to the delete-ful // branch below would have left this door standing open. if (targetState === 'active') { - await this.restoreArtifactRegistryView(request.type, request.name, orgId); + await this.restoreArtifactRegistryView(request.type, request.name, orgId, member); } return { success: true, @@ -26605,6 +26901,7 @@ export class ObjectStackProtocolImplementation implements ? 'override-artifact' : 'runtime-only', state: targetState, + ...memberOption(member), }); // Heal the registry: drop the overlay's runtime shadow so the @@ -26622,7 +26919,7 @@ export class ObjectStackProtocolImplementation implements // ENV-WIDE row, i.e. one tenant's "reset to default" blanked // the item for every other tenant and the control plane. if (targetState === 'active') { - await this.restoreArtifactRegistryView(request.type, request.name, orgId); + await this.restoreArtifactRegistryView(request.type, request.name, orgId, member); } // Storage teardown (opt-in): drop the now-orphaned physical table @@ -26658,6 +26955,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'deleted', organizationId: orgId, + ...mutationEventMember(singularTypeForRepo, member), }); return { success: true, diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index da58b694f38..d50f218cd40 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -279,6 +279,18 @@ export function historyRowsOfMember>( }); } +/** + * [#22877] A stored `sys_metadata` row's bundle member as a header spread: + * `{ member }` for a non-canonical member of a type whose identity is a pair, + * `{}` for the canonical member and for every type with no members — so a + * header of those rows keeps exactly the keys it always had. + */ +function storedRowMemberHeader(row: Record): { member?: string } { + const stored = row[OVERLAY_MEMBER_COLUMN]; + const value = overlayMemberValue(String(row.type), typeof stored === 'string' ? stored : null); + return typeof value === 'string' ? { member: value } : {}; +} + /** * Overlay-row lifecycle state. * @@ -1485,6 +1497,15 @@ export class SysMetadataRepository implements MetadataRepository { packageId: string | null; updatedAt: string | null; updatedBy: string | null; + /** + * [#22877] The bundle member the draft row belongs to, for a type whose + * identity is a pair (`'zh-CN'` of an `email_template`) — its stored + * `bundle_member`. Absent for the canonical member and for every type + * with no members. The package-batch doors read it so each draft is + * promoted or discarded as the member it is; it is a header of THIS + * repository, and the protocol's draft list does not serve it. + */ + member?: string; }> > { this.assertOpen(); @@ -1494,6 +1515,7 @@ export class SysMetadataRepository implements MetadataRepository { return (rows as any[]).map((row) => ({ type: row.type, name: row.name, + ...storedRowMemberHeader(row), // [#22200] Off the row this read already holds — no second query. label: draftBodyLabel(row), packageId: row.package_id ?? null, diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 45723344c89..18461c2d61c 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2023,6 +2023,22 @@ function collectBundle( return { bare: canonicalFirst(bare), local: canonicalFirst(local), other: canonicalFirst(other) }; } +/** + * [#22877] `entries` narrowed to ONE member of their bundle, order kept. An + * entry's member is its discriminator value, or the canonical member for an + * item that declares none — the value {@link SchemaRegistry.registerItem} keys + * it by. `member` is a discriminator value as a caller names it (`'zh-CN'`); a + * blank one names the canonical member, as `itemDiscriminator` reads a body. + */ +function entriesOfMember( + entries: readonly BundleEntry[], + disc: { field: string; canonical: string }, + member: string, +): BundleEntry[] { + const wanted = member.trim() || disc.canonical; + return entries.filter((e) => (discriminatorValue(e.item, disc.field) || disc.canonical) === wanted); +} + /** * `schema` without the field `name` — `schema` itself, by reference, when it * does not carry one. Never mutates its input. @@ -4594,8 +4610,13 @@ export class SchemaRegistry { * 1. bare-key runtime/DB overlay (ADR-0005 sanctioned override) — unchanged * 2. the `currentPackageId` composite entry (prefer-local) * 3. first composite match (legacy first-registered-wins fallback) + * + * @param member - [#22877] For a type whose identity is a pair + * ({@link ITEM_KEY_DISCRIMINATORS}), the bundle member to answer, as its + * discriminator value (`'zh-CN'`); omitted, the canonical member. Ignored + * for every other type, which has no members. */ - getItem(type: string, name: string, currentPackageId?: string): T | undefined { + getItem(type: string, name: string, currentPackageId?: string, member?: string): T | undefined { // Special handling for 'object' and 'objects' types - use objectContributors if (type === 'object' || type === 'objects') { return this.getObject(name) as unknown as T | undefined; @@ -4606,15 +4627,22 @@ export class SchemaRegistry { // [#7730] A discriminated type's name addresses a BUNDLE, so every tier // below is a group rather than a single key. The tiers themselves are - // unchanged (overlay → prefer-local → first composite); within a tier the - // canonical member wins, which is the locale `sendTemplate` falls back to. - // A caller that needs a specific member reads the bundle from - // `listItems(type)` and picks on the discriminator — this API takes a name - // and can only answer about the name. + // unchanged (overlay → prefer-local → first composite). + // + // [#22877] A bare-name read answers the CANONICAL member — the one the + // `/meta` by-name doors address when no member is named — wherever it + // sits in that tier order. It used to take the first tier that held ANY + // member and pick canonical-first only inside it, so an overlay of a + // second locale alone (a template customized in `zh-CN` only) answered a + // read that named no member. Only a bundle with no canonical member at + // all still answers its first member, as it always did. A named `member` + // answers that member, in the same tier order, or nothing. const disc = ITEM_KEY_DISCRIMINATORS[type]; if (disc) { const { bare, local, other } = collectBundle(collection, name, disc, currentPackageId); - return ([...bare, ...local, ...other][0]?.item as T) ?? undefined; + const tiers = [...bare, ...local, ...other]; + if (member !== undefined) return (entriesOfMember(tiers, disc, member)[0]?.item as T) ?? undefined; + return ((entriesOfMember(tiers, disc, disc.canonical)[0] ?? tiers[0])?.item as T) ?? undefined; } // A bare-key entry (a runtime/DB overlay rehydrated by restoreMetadataFromDb) @@ -4655,8 +4683,12 @@ export class SchemaRegistry { * overlay row hydrated into the plain key must never be able to mask * it (that masking is exactly the "registry pollution" bug where a * locked app's `_lock` read back as undefined after a PUT+GET). + * + * @param member - [#22877] As on {@link getItem}: one bundle member's + * artifact, for a type whose identity is a pair. Omitted, the bundle's + * artifact canonical-first, as before. */ - getArtifactItem(type: string, name: string, currentPackageId?: string): T | undefined { + getArtifactItem(type: string, name: string, currentPackageId?: string, member?: string): T | undefined { if (type === 'object' || type === 'objects') { // [ADR-0029 D9.6] Artifact identity is read from the OWNER contributor's // layer, NEVER from the merged body. The merged body deliberately keeps @@ -4687,10 +4719,11 @@ export class SchemaRegistry { const disc = ITEM_KEY_DISCRIMINATORS[type]; if (disc) { const { bare, local, other } = collectBundle(collection, name, disc, currentPackageId); - for (const { item } of [...local, ...other]) { + const narrow = (entries: BundleEntry[]) => (member === undefined ? entries : entriesOfMember(entries, disc, member)); + for (const { item } of narrow([...local, ...other])) { if (isCodeArtifactBody(item)) return item as T; } - for (const { item } of bare) { + for (const { item } of narrow(bare)) { if (isCodeArtifactBody(item) && (!currentPackageId || item._packageId === currentPackageId)) return item as T; } return undefined; @@ -4743,24 +4776,37 @@ export class SchemaRegistry { * onto the shadow (ADR-0010 §3.3), so a stamped `_packageId` does not * mean the plain entry IS the artifact registration; artifact loaders * always register under a composite key. + * + * @param member - [#22877] For a type whose identity is a pair, the ONE + * bundle member whose shadow to lift, as its discriminator value. Named, + * only that member's shadow goes, and only while the artifact still ships + * that member. Omitted, the bundle's whole shadow group, as before. */ - removeRuntimeShadow(type: string, name: string): boolean { + removeRuntimeShadow(type: string, name: string, member?: string): boolean { const collection = this.metadata.get(type); if (!collection) return false; // [#7730] For a discriminated type the shadow sits at `name@` // — nothing is ever stored under the plain name — so the plain-key test - // would silently answer "no shadow" for every email template. The overlay - // layer is keyed by name alone (`sys_metadata` is unique on type+name+org), - // so the shadow group is swept as one, exactly as the delete event - // addresses it. + // would silently answer "no shadow" for every email template. + // + // [#22877] The overlay store now keys a row by its member + // (`sys_metadata.bundle_member`), so a by-name reset removes ONE member's + // row and the heal that follows it names that member: resetting `zh-CN` + // must leave the `en-US` overlay registered, because its row is still + // stored and still served. Sweeping the group, as this did while the + // store kept one row per name, dropped every other member's overlay until + // the next boot. A caller naming no member keeps the group sweep, the + // shape `unregisterItem` takes for a withdrawal by name. const disc = ITEM_KEY_DISCRIMINATORS[type]; if (disc) { const { bare, local, other } = collectBundle(collection, name, disc); - if (bare.length === 0) return false; - const artifact = [...local, ...other].find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); + const narrow = (entries: BundleEntry[]) => (member === undefined ? entries : entriesOfMember(entries, disc, member)); + const shadows = narrow(bare); + if (shadows.length === 0) return false; + const artifact = narrow([...local, ...other]).find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); if (!artifact) return false; - for (const { key } of bare) { + for (const { key } of shadows) { collection.delete(key); this.log(`[Registry] Removed runtime shadow ${type}: ${key} (artifact ${artifact.item._packageId} restored)`); } @@ -4816,9 +4862,11 @@ export class SchemaRegistry { * package-stamped item can land here; unregistering shipped code that the * overlay delete never touched would be a worse bug than the one this fixes. * + * @param member - [#22877] As on {@link removeRuntimeShadow}: the ONE bundle + * member whose entry to retire; omitted, the whole overlay group. * @returns whether an entry was removed. */ - removeOverlayEntry(type: string, name: string): boolean { + removeOverlayEntry(type: string, name: string, member?: string): boolean { const collection = this.metadata.get(type); if (!collection) return false; @@ -4827,11 +4875,12 @@ export class SchemaRegistry { // finds nothing and the deleted overlay would keep being served for the // life of the process — the very residue #5079 removed. The refusal is // still per entry: a bundle member that IS a packaged artifact stays. + // [#22877] …and a named member retires that member's entry only. const disc = ITEM_KEY_DISCRIMINATORS[type]; if (disc) { const { bare } = collectBundle(collection, name, disc); let removed = false; - for (const { key, item } of bare) { + for (const { key, item } of member === undefined ? bare : entriesOfMember(bare, disc, member)) { if (isCodeArtifactBody(item)) continue; collection.delete(key); this.log(`[Registry] Removed overlay entry ${type}: ${key} (no layer serves it any more)`); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d87b432c830..42e0bc79bc1 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -708,6 +708,83 @@ export const GLOBAL_SEARCH_PARAMS: readonly string[] = [ 'q', 'query', 'objects', 'limit', 'perObject', ]; +/** + * [#22877] The bundle member selector's query parameter on the by-name `/meta` + * doors (ruling 6116165299, Q2 i): GET, DELETE, `/history`, `/publish` and + * `/rollback` on `/meta/:type/:name` each carry it in their closed set below. + * Named after the discriminator's concept (`sys_metadata.bundle_member`) and + * type-neutral — ⛔ never `locale`, which on `/meta` already names the + * response translation locale (`extractLocale`). + */ +export const META_ITEM_MEMBER_PARAM = 'member'; + +/** + * [#22877] The closed query-parameter set of `GET {metaPath}/:type/:name`, + * which declared none before the selector joined it. + * + * **Measured** from the handler's read points and the helpers it calls: + * `layers` (the deprecated layered spelling, `wantsMetaItemLayers`), `state` + * and `preview` (the two admitted draft switches), `package` (the cache bypass, + * `metaItemPackageBinding`, and the layered read's `serveMetaItemLayered`), + * and `locale` — read a frame down, by `extractLocale` behind the cached arm's + * validator and every `translateMetaEnvelope` / `createMetaItemAnswer` + * translation, never in the handler body; omitting it would 400 every + * `?locale=zh-CN` read that works today. Plus {@link META_ITEM_MEMBER_PARAM}. + */ +export const META_ITEM_READ_PARAMS: readonly string[] = [ + 'layers', 'state', 'preview', 'package', 'locale', META_ITEM_MEMBER_PARAM, +]; + +/** + * [#22877] The closed set of `DELETE {metaPath}/:type/:name` (the reset). + * **Measured**: `state` (the draft discard) and `dropStorage` (the opt-in + * table teardown); the OCC pin and the actor ride headers and the session. + * Plus {@link META_ITEM_MEMBER_PARAM}. + */ +export const META_ITEM_RESET_PARAMS: readonly string[] = ['state', 'dropStorage', META_ITEM_MEMBER_PARAM]; + +/** + * [#22877] The closed set of `GET {metaPath}/:type/:name/history`. + * **Measured**: the paging pair `sinceSeq` and `limit`, each read through its + * declaration (`readDeclaredQueryNumber`); the authoring-door and per-caller + * refusals read the session, not the query. Plus {@link META_ITEM_MEMBER_PARAM}. + */ +export const META_ITEM_HISTORY_PARAMS: readonly string[] = ['sinceSeq', 'limit', META_ITEM_MEMBER_PARAM]; + +/** + * [#22877] The closed set of `POST {metaPath}/:type/:name/publish`. + * **Measured**: `package` (the draft's package binding, + * `metaItemPackageBinding`); the message rides the body, the actor the + * session. Plus {@link META_ITEM_MEMBER_PARAM}. + */ +export const META_ITEM_PUBLISH_PARAMS: readonly string[] = ['package', META_ITEM_MEMBER_PARAM]; + +/** + * [#22877] The closed set of `POST {metaPath}/:type/:name/rollback`. + * **Measured**: `toVersion`, the query fallback after the body's `toVersion` / + * `version`; the message rides the body, the actor the session. Plus + * {@link META_ITEM_MEMBER_PARAM}. + */ +export const META_ITEM_ROLLBACK_PARAMS: readonly string[] = ['toVersion', META_ITEM_MEMBER_PARAM]; + +/** + * [#22877] THE reading of the bundle member selector on every by-name `/meta` + * door, as the spread each door's protocol request takes: `{ member }` when + * the caller named one, `{}` when not, which the protocol reads as the + * canonical member. The VALUE is the protocol's to judge, once, for every + * transport (`resolveBundleMemberSelector` in `@objectstack/metadata-protocol`): + * a selector on a type with no members, a blank one or an overlong one is + * answered there with the located 400. A value that is not a string (an + * adapter's nested-object parse) is handed over as received, so that one + * reading refuses it — ⛔ never dropped here, which would answer the canonical + * member to a request that named something. Recognition of the NAME is each + * route's closed set above; repetition, `refuseRepeatedQueryParams`. + */ +function metaMemberSelector(query: unknown): { member?: string } { + const raw = (query as Record | null | undefined)?.[META_ITEM_MEMBER_PARAM]; + return raw === undefined ? {} : { member: raw as string }; +} + /** * [#20062 / #20139] The reading of a numeric query parameter on a door whose * request has no declared schema: a whole number, and nothing about range. @@ -6298,7 +6375,12 @@ export class RestServer { // lines apart (`?layers=`, `?state=`, `?preview=`, // `?package=`), and a per-branch gate is one a new branch // inherits by accident rather than by construction. - if (refuseRepeatedQueryParams(req, res, ['layers', 'state', 'preview', 'package'])) return; + // [#22877] …and RECOGNITION first, over the route's + // measured closed set (AGENTS.md route rule 5), which + // the bundle member selector joined. + if (refuseUnknownQueryParams(req, res, META_ITEM_READ_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['layers', 'state', 'preview', 'package', META_ITEM_MEMBER_PARAM])) return; + const memberSelector = metaMemberSelector(req.query); const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); @@ -6396,6 +6478,16 @@ export class RestServer { // helper prescribes for a transport that cannot say where // it serves the item. const wantLayered = metaReadGate.wantsMetaItemLayers(req.query); + // [#22877] The layered view reads no member, so a member + // named on it is refused rather than answered with the + // canonical member's layers. + if (wantLayered && memberSelector.member !== undefined) { + throw Object.assign(new Error( + `The "${META_ITEM_MEMBER_PARAM}" selector is not served by the layered view ` + + `(\`?layers=\`), which shows the canonical member's layers. Read one member with ` + + `\`?${META_ITEM_MEMBER_PARAM}=\` alone.`, + ), { code: 'VALIDATION_ERROR', status: 400 }); + } if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { const requestPath: unknown = req.path; const deprecation = metaReadGate.metaItemLayersDeprecationHeaders( @@ -6513,7 +6605,12 @@ export class RestServer { // ADR-0046 §6.7 — the two audience-gated types, excluded // from the cache so {@link metaItemReadGate} judges them. const isAudienceGatedType = metaType === 'book' || metaType === 'doc'; - if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !isAudienceGatedType) { + // [#22877] A member-addressed read bypasses the cache + // too: the cached read takes no member and keys its + // validator on type and name, so it can only answer the + // canonical member. + const memberScoped = memberSelector.member !== undefined; + if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !memberScoped && !isAudienceGatedType) { // [ADR-0106 D3] When a projection applies, the // protocol is NOT allowed to judge the conditional // request: `getMetaItemCached` hashes the UNFILTERED @@ -6761,6 +6858,8 @@ export class RestServer { // never `req.query` re-read here. ...(isDraftRead ? { state: 'draft' as const } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), + // [#22877] The member, read once above. + ...memberSelector, }; const envelope = await p.getMetaItem(itemRequest) as Record; @@ -7238,7 +7337,9 @@ export class RestServer { // matching), but "the wrong answer happens to be the // conservative one" is not a rule a caller can rely on and // is not what the request asked for. - if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage'])) return; + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_RESET_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['state', 'dropStorage', META_ITEM_MEMBER_PARAM])) return; const stateParam = typeof req.query?.state === 'string' && req.query.state.toLowerCase() === 'draft' ? 'draft' as const @@ -7276,6 +7377,8 @@ export class RestServer { ...(actor ? { actor } : {}), ...(stateParam ? { state: stateParam } : {}), ...(dropStorage ? { dropStorage: true } : {}), + // [#22877] Resets ONE member of a bundled type. + ...metaMemberSelector(req.query), }; const result = await p.deleteMetaItem(deleteRequest); res.json(result); @@ -7350,7 +7453,9 @@ export class RestServer { // `Number.isFinite` spreads below drop — so a repeated // `?limit=` silently returned the UNLIMITED history instead // of the page the caller asked for. - if (refuseRepeatedQueryParams(req, res, ['sinceSeq', 'limit'])) return; + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_HISTORY_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['sinceSeq', 'limit', META_ITEM_MEMBER_PARAM])) return; // [#20139] `sinceSeq` is parsed through its DECLARATION // (`HistoryMetaItemRequestSchema.sinceSeq`, `z.number().optional()`), // the way `limit` is just below: `?sinceSeq=abc` used to be `NaN`, @@ -7409,6 +7514,8 @@ export class RestServer { // refuse anything else, so no `Number.isFinite` drop is left here. ...(sinceSeq !== undefined ? { sinceSeq } : {}), ...(limit !== undefined ? { limit } : {}), + // [#22877] ONE member's events of the bundle's lineage. + ...metaMemberSelector(req.query), }; const result = await p.historyMetaItem(historyRequest); res.json(result); @@ -7713,7 +7820,9 @@ export class RestServer { // path — narrowing that never fires while looking like it // does. Widening `MetadataItem` is a `packages/spec` contract // change and stays filed rather than taken here. - if (refuseRepeatedQueryParams(req, res, ['package'])) return; + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_PUBLISH_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['package', META_ITEM_MEMBER_PARAM])) return; const packageId = metaItemPackageBinding(req.query?.package); // [ADR-0131 D6] No organization, as on the `PUT` twin: a @@ -7757,6 +7866,8 @@ export class RestServer { ...(actor ? { actor } : {}), ...(message ? { message } : {}), ...(packageId ? { packageId } : {}), + // [#22877] Publishes ONE member's draft of a bundled type. + ...metaMemberSelector(req.query), }; const result = await p.publishMetaItem(publishRequest); res.json(result); @@ -7850,7 +7961,9 @@ export class RestServer { // is required", which tells a caller who supplied two valid // integers that they supplied none. Refusing the multiplicity // by name says what actually happened. - if (refuseRepeatedQueryParams(req, res, ['toVersion'])) return; + // [#22877] Recognition first, over the measured closed set. + if (refuseUnknownQueryParams(req, res, META_ITEM_ROLLBACK_PARAMS)) return; + if (refuseRepeatedQueryParams(req, res, ['toVersion', META_ITEM_MEMBER_PARAM])) return; const body = (req.body && typeof req.body === 'object') ? req.body : {}; const toVersionRaw = body.toVersion ?? body.version ?? req.query?.toVersion; const toVersion = Number(toVersionRaw); @@ -7875,6 +7988,8 @@ export class RestServer { ...(environmentId ? { environmentId } : {}), ...(actor ? { actor } : {}), ...(message ? { message } : {}), + // [#22877] Reverts ONE member of a bundled type. + ...metaMemberSelector(req.query), }); res.json(result); } catch (error: any) { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7119c42df42..fda0d0f3e90 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -257,6 +257,21 @@ export const GetMetaItemsResponseSchema = lazySchema(() => z.object({ items: z.array(z.unknown()).describe('Array of metadata items'), })); +/** + * [#22877] The `member` every by-name door that reads or changes ONE stored + * item declares (get, delete, publish, history; revert has no declared + * request) — one sentence, so the four requests cannot drift apart. On the + * REST door it is the `?member=` query parameter of each route's closed set. + */ +const BUNDLE_MEMBER_SELECTOR_DESCRIPTION = + '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.'; + /** * Get Metadata Item Request * Get a specific metadata item by type and name @@ -265,6 +280,7 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name (snake_case identifier)'), packageId: z.string().optional().describe('Optional package ID to filter items by'), + member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), state: z.enum(['active', 'draft']).optional().describe( 'Draft-visibility switch — which lifecycle row to read (strict mode): ' + '`\'draft\'` opens the pending draft buffer (Studio\'s editor read) and ' @@ -902,6 +918,7 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ + 'package-bound draft unfindable — a silent `no_draft` on the untouched ' + 'path.', ), + member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), })); /** @@ -1363,6 +1380,7 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ + 'state only; never `sys_` tables). Used by the "discard a previewed ' + 'object" flow so a publish-to-preview leaves no orphan table.', ), + member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), })); /** @@ -1579,6 +1597,7 @@ export const HistoryMetaItemRequestSchema = lazySchema(() => z.object({ + 'declaring `.min()`/`.max()` here would refuse values the shipped ' + 'verb accepts.)', ), + member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), })); /** From f321dc7ead866d56ad5a067da378b7b0d1509b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:21:37 +0000 Subject: [PATCH 2/7] test(objectql,rest,metadata-protocol): pins for the by-name member selector and its closed query sets (#22877, stage 2) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-member-selector.md | 39 +++ .../protocol.bundle-member-selector.test.ts | 69 +++++ ...ol.lookup-artifact-item-call-sites.test.ts | 6 +- packages/metadata-protocol/src/protocol.ts | 30 +- ...nment-overlay-email-locale-restart.test.ts | 244 +++++++++++++++- .../src/registry-i18n-bundle-key.test.ts | 57 ++++ ...e-two-locales-survive-boot.dogfood.test.ts | 39 ++- .../rest-server-meta-member-selector.test.ts | 272 ++++++++++++++++++ packages/spec/authorable-surface/api.json | 4 + 9 files changed, 734 insertions(+), 26 deletions(-) create mode 100644 .changeset/22877-member-selector.md create mode 100644 packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts create mode 100644 packages/rest/src/rest-server-meta-member-selector.test.ts diff --git a/.changeset/22877-member-selector.md b/.changeset/22877-member-selector.md new file mode 100644 index 00000000000..9557ceeaec5 --- /dev/null +++ b/.changeset/22877-member-selector.md @@ -0,0 +1,39 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/rest': minor +'@objectstack/spec': minor +'@objectstack/objectql': patch +--- + +feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + +Clause-②: yes + +**What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + +- `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). +- `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. +- `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. +- `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. +- `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + +With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + +**The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + +**Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + +- `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. +- `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. +- `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. +- `POST /meta/:type/:name/publish`: `package`, `member`. +- `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + +A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + +**What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + +- A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. +- Resetting one language keeps the other languages' customizations served at once, not only after the next restart. +- Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. +- In a cluster, a save, reset or revert of one language converges the other servers on that language. diff --git a/packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts b/packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts new file mode 100644 index 00000000000..af3cc056fdf --- /dev/null +++ b/packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22877] THE one reading of the by-name doors' bundle member selector + * (`resolveBundleMemberSelector`, ruling 6116165299 Q2 i): absent means the + * canonical member, a well-formed member passes as named (trimmed), and a + * wrong value is a located 400 `VALIDATION_ERROR` — the selector on a type + * with no members, a value that is not a string, a blank one, and one longer + * than a stored member can be. The doors that ask it are pinned on the real + * engine in `@objectstack/objectql`'s `environment-overlay-email-locale-restart.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import { SysMetadataObject } from '@objectstack/metadata-core'; +import { BUNDLE_MEMBER_MAX_LENGTH, BUNDLE_MEMBER_SELECTOR, resolveBundleMemberSelector } from './protocol.js'; + +function refusal(type: string, member: unknown): { status?: number; code?: string; param?: string; message: string } { + try { + resolveBundleMemberSelector(type, member); + } catch (e: any) { + return { status: e.status, code: e.code, param: e.param, message: String(e.message) }; + } + throw new Error(`expected ${JSON.stringify(member)} on '${type}' to be refused`); +} + +describe('[#22877] resolveBundleMemberSelector', () => { + it('is named `member`, never `locale`', () => { + expect(BUNDLE_MEMBER_SELECTOR).toBe('member'); + }); + + it('absent means the canonical member, for every type', () => { + expect(resolveBundleMemberSelector('email_template', undefined)).toBeUndefined(); + expect(resolveBundleMemberSelector('view', undefined)).toBeUndefined(); + }); + + it('a well-formed member passes as named, trimmed — the canonical value included', () => { + expect(resolveBundleMemberSelector('email_template', 'zh-CN')).toBe('zh-CN'); + expect(resolveBundleMemberSelector('email_template', ' zh-CN ')).toBe('zh-CN'); + expect(resolveBundleMemberSelector('email_template', 'en-US')).toBe('en-US'); + // Either spelling of the type. + expect(resolveBundleMemberSelector('email_templates', 'zh-CN')).toBe('zh-CN'); + expect(resolveBundleMemberSelector('email_template', 'x'.repeat(BUNDLE_MEMBER_MAX_LENGTH))).toHaveLength(100); + }); + + it('a type with no members refuses the selector, located', () => { + const r = refusal('view', 'zh-CN'); + expect(r).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'member' }); + expect(r.message).toBe( + 'The "member" selector names a bundle member, and a \'view\' item has none: its identity is its name alone. ' + + 'Remove the selector.', + ); + }); + + it('a value that cannot name a member is refused: not a string, blank, or overlong', () => { + expect(refusal('email_template', ['zh-CN'])).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'member' }); + expect(refusal('email_template', { zh: 'CN' }).message).toMatch(/^The "member" selector must be one string: the locale of one 'email_template' member/); + const blank = refusal('email_template', ' '); + expect(blank).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(blank.message).toMatch(/^The "member" selector is blank\. .*omit the selector to address the canonical member \('en-US'\)\.$/); + const long = refusal('email_template', 'x'.repeat(BUNDLE_MEMBER_MAX_LENGTH + 1)); + expect(long).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(long.message).toMatch(/^The "member" selector is 101 characters long/); + }); + + it('the length bound IS the stored column\'s — a longer selector names no member that could be stored', () => { + const column = (SysMetadataObject as any).fields.bundle_member; + expect(column.maxLength).toBe(BUNDLE_MEMBER_MAX_LENGTH); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index 90cc62d81c2..e4b4e4411d9 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -68,7 +68,7 @@ type Disposition = 'definition' | 'served-package envelope' | 'no'; * envelope never reaches an answer whose body came from another package. */ const SITES: ReadonlyArray = [ - { member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string', noPackage: false, disposition: 'definition', + { 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)' }, @@ -76,8 +76,8 @@ const SITES: ReadonlyArray>>) { +async function boot( + stores: Map>>, + /** [#22877] `'control-plane'` boots a kernel with no environment, whose registry a save writes through to. */ + kernel: 'environment' | 'control-plane' = 'environment', +) { const engine = new ObjectQL(); engine.registerDriver(makeMemoryDriver(stores), true); await engine.init(); @@ -154,7 +158,9 @@ async function boot(stores: Map>>) { engine.registry.registerObject(o as any, 'test-package'); } engine.registerApp(mailManifest()); - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod'); + const protocol = new ObjectStackProtocolImplementation( + engine, () => new Map(), kernel === 'environment' ? 'env_prod' : undefined, + ); const load = await protocol.loadMetaFromDb(); return { engine, protocol, load }; } @@ -288,3 +294,235 @@ describe('two locales of one email template survive a restart (#22877)', () => { expect(await servedSubjects(second.protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'ADMIN zh' }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Stage 2 — the by-name member selector (ruling 6116165299, Q2 i) +// ───────────────────────────────────────────────────────────────────────────── +// +// GET, DELETE, publish, history and revert by name accept ONE member of the +// bundle (`member`, the discriminator's value); absent, the canonical member. +// A wrong value is a located 400. One lineage counter per `(org, type, name)` +// is shared by the bundle; history and revert are filtered by member, and a +// revert to another member's version answers `404 VERSION_NOT_FOUND` (the +// seat's answers 6118224899). Same harness as above: real engine, real +// protocol, and a restart is a second boot over the same stores. + +const T = 'email_template'; + +const subjectOf = async (protocol: ObjectStackProtocolImplementation, member?: string) => + ((await protocol.getMetaItem({ type: T, name: NAME, ...(member !== undefined ? { member } : {}) })) as any)?.item?.subject; + +/** The registry's own entry for one member — what a peer, a send or a bare-name engine read is served. */ +const registrySubject = (engine: ObjectQL, member: string) => + (engine.registry.getItem(T, NAME, undefined, member))?.subject; + +/** Save both locales, then restart, so the second boot's registry holds both hydrated overlays. */ +async function bootWithBothSaved(stores: Map>>) { + const first = await boot(stores); + await first.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); + await first.protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh') }); + return boot(stores); +} + +/** The located 400 the selector's one reading raises, whichever door asked it. */ +async function expectSelectorRefusal(call: Promise, sentence: RegExp) { + const err: any = await call.then(() => undefined, (e: unknown) => e); + expect(err, 'expected the selector to be refused').toBeDefined(); + expect(err.status).toBe(400); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(String(err.message)).toMatch(sentence); +} + +describe('the by-name doors address ONE member by the selector (#22877, stage 2)', () => { + it('GET: each member by name; absent and the canonical value both answer the canonical member', async () => { + const stores = new Map>>(); + const { protocol } = await bootWithBothSaved(stores); + expect(await subjectOf(protocol)).toBe('ADMIN en'); + expect(await subjectOf(protocol, 'en-US')).toBe('ADMIN en'); + expect(await subjectOf(protocol, 'zh-CN')).toBe('ADMIN zh'); + // Trimmed, as the stored member was. + expect(await subjectOf(protocol, ' zh-CN ')).toBe('ADMIN zh'); + }); + + it('GET: a member nobody stored answers the code layer\'s member; one no layer holds answers nothing', async () => { + const stores = new Map>>(); + const { protocol } = await boot(stores); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); + expect(await subjectOf(protocol, 'zh-CN')).toBe('PKG zh'); + const absent: any = await protocol.getMetaItem({ type: T, name: NAME, member: 'fr-FR' }); + expect(absent?.item).toBeUndefined(); + }); + + it('DELETE by member: resetting zh-CN removes its row only, and the en-US overlay stays served — the registry included', async () => { + const stores = new Map>>(); + const { engine, protocol } = await bootWithBothSaved(stores); + expect(registrySubject(engine, 'en-US')).toBe('ADMIN en'); + expect(registrySubject(engine, 'zh-CN')).toBe('ADMIN zh'); + + expect(await protocol.deleteMetaItem({ type: T, name: NAME, member: 'zh-CN' })).toMatchObject({ success: true, reset: true }); + expect(overlayRows(stores).map((r) => r.bundle_member ?? null)).toEqual([null]); + // The reset member is back on the package wording; the other member's + // overlay is still the registry's entry — not swept with the group. + expect(registrySubject(engine, 'zh-CN')).toBe('PKG zh'); + expect(registrySubject(engine, 'en-US')).toBe('ADMIN en'); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); + expect(await subjectOf(protocol, 'zh-CN')).toBe('PKG zh'); + + const after = await boot(stores); + expect(await servedSubjects(after.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); + expect(registrySubject(after.engine, 'en-US')).toBe('ADMIN en'); + }); + + it('DELETE with no member (the reverse): the canonical member is reset and the zh-CN overlay stays registered', async () => { + const stores = new Map>>(); + const { engine, protocol } = await bootWithBothSaved(stores); + expect(await protocol.deleteMetaItem({ type: T, name: NAME })).toMatchObject({ success: true, reset: true }); + expect(overlayRows(stores).map((r) => r.bundle_member)).toEqual(['zh-CN']); + expect(registrySubject(engine, 'en-US')).toBe('PKG en'); + expect(registrySubject(engine, 'zh-CN')).toBe('ADMIN zh'); + expect(await subjectOf(protocol)).toBe('PKG en'); + }); + + it('publish by member: a zh-CN draft goes live by name; CONTROL the bare publish finds no canonical draft', async () => { + const stores = new Map>>(); + const { protocol } = await boot(stores); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft' }); + + const bare: any = await protocol.publishMetaItem({ type: T, name: NAME }).then(() => undefined, (e: unknown) => e); + expect(bare?.code).toBe('NO_DRAFT'); + expect(await protocol.publishMetaItem({ type: T, name: NAME, member: 'zh-CN' })).toMatchObject({ success: true }); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'PKG en', 'zh-CN': 'DRAFT zh' }); + expect(await subjectOf(protocol, 'zh-CN')).toBe('DRAFT zh'); + expect(await subjectOf(protocol)).toBe('PKG en'); + }); + + it('history and revert by member: one shared counter, each member its own events; another member\'s version is VERSION_NOT_FOUND', async () => { + const stores = new Map>>(); + const { protocol } = await boot(stores); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh 1') }); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh 2') }); + + const zh = (await protocol.historyMetaItem({ type: T, name: NAME, member: 'zh-CN' })).events; + const en = (await protocol.historyMetaItem({ type: T, name: NAME })).events; + expect(zh).toHaveLength(2); + expect(en).toHaveLength(1); + // One lineage counter per name: the versions interleave and never collide. + const versions = [...en, ...zh].map((e) => e.version); + expect(new Set(versions).size).toBe(3); + + const [zhFirst] = zh; + expect(await protocol.rollbackMetaItem({ type: T, name: NAME, toVersion: zhFirst!.version!, member: 'zh-CN' })) + .toMatchObject({ success: true, restoredFromVersion: zhFirst!.version }); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh 1' }); + + const crossed: any = await protocol.rollbackMetaItem({ type: T, name: NAME, toVersion: en[0]!.version!, member: 'zh-CN' }) + .then(() => undefined, (e: unknown) => e); + expect(crossed?.code).toBe('VERSION_NOT_FOUND'); + expect(crossed?.status).toBe(404); + // CONTROL: the canonical member reverts to its own version by the bare name. + expect(await protocol.rollbackMetaItem({ type: T, name: NAME, toVersion: en[0]!.version! })).toMatchObject({ success: true }); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh 1' }); + }); + + it('a wrong selector is a located 400 on every door, and nothing is read or written', async () => { + const stores = new Map>>(); + const { protocol } = await bootWithBothSaved(stores); + const before = JSON.stringify(Array.from(stores.get('sys_metadata')!.values())); + const noMembers = /^The "member" selector names a bundle member, and a 'view' item has none/; + const view = { type: 'view', name: 'task_grid', member: 'zh-CN' }; + await expectSelectorRefusal(protocol.getMetaItem(view), noMembers); + await expectSelectorRefusal(protocol.deleteMetaItem(view), noMembers); + await expectSelectorRefusal(protocol.publishMetaItem(view), noMembers); + await expectSelectorRefusal(protocol.historyMetaItem(view), noMembers); + await expectSelectorRefusal(protocol.rollbackMetaItem({ ...view, toVersion: 1 }), noMembers); + + await expectSelectorRefusal(protocol.deleteMetaItem({ type: T, name: NAME, member: '' }), /^The "member" selector is blank\./); + await expectSelectorRefusal( + protocol.getMetaItem({ type: T, name: NAME, member: 'x'.repeat(101) }), + /^The "member" selector is 101 characters long; a stored 'email_template' member holds at most 100/, + ); + expect(JSON.stringify(Array.from(stores.get('sys_metadata')!.values()))).toBe(before); + }); +}); + +describe('the package-batch doors and the cluster fan-out carry the member (#22877, stage 2)', () => { + /** A zh-CN draft bound to the package, beside an en-US active overlay that has none. */ + async function bootWithZhDraft(stores: Map>>) { + const booted = await boot(stores); + await booted.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); + await booted.protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft', packageId: PKG }); + return booted; + } + const draftRows = (stores: Map>>) => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === T && r.name === NAME && r.state === 'draft'); + + it('publishPackageDrafts: the zh-CN draft alone is promoted as zh-CN, and en-US is untouched', async () => { + const stores = new Map>>(); + const { protocol } = await bootWithZhDraft(stores); + expect(draftRows(stores).map((r) => r.bundle_member)).toEqual(['zh-CN']); + + const out: any = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(out, JSON.stringify(out)).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(draftRows(stores)).toEqual([]); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'DRAFT zh' }); + }); + + it('revertCommit: the revert of that publish removes the zh-CN row it created and leaves en-US', async () => { + const stores = new Map>>(); + const { protocol } = await bootWithZhDraft(stores); + const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(published.commitId, JSON.stringify(published)).toBeTruthy(); + + const reverted: any = await protocol.revertCommit({ commitId: published.commitId }); + expect(reverted, JSON.stringify(reverted)).toMatchObject({ success: true, revertedCount: 1 }); + expect(overlayRows(stores).map((r) => r.bundle_member ?? null)).toEqual([null]); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); + // The timeline serves the declared item, with no `member` key. + const commits: any[] = await (protocol as any).listCommits({ packageId: PKG }); + for (const c of commits) for (const it of c.items) expect(Object.keys(it).sort()).toEqual(['existedBefore', 'name', 'prevVersion', 'type']); + }); + + it('discardPackageDrafts: the zh-CN draft is discarded as zh-CN', async () => { + const stores = new Map>>(); + const { protocol } = await bootWithZhDraft(stores); + const out: any = await protocol.discardPackageDrafts({ packageId: PKG }); + expect(out, JSON.stringify(out)).toMatchObject({ success: true, discardedCount: 1, failedCount: 0 }); + expect(draftRows(stores)).toEqual([]); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); + // The public draft list keeps its declared header (no `member`). + const listed: any = await protocol.listDrafts({ packageId: PKG }); + expect(listed.drafts).toEqual([]); + }); + + it('the cluster fan-out: a peer converges on the member a write changed, and a member reset heals only that member', async () => { + // Control-plane replicas: their registry takes the write-through a + // converging peer re-runs (an environment kernel keeps a non-object + // overlay out of it until the next boot, #6602). + const stores = new Map>>(); + const subscribers: Array<(msg: { payload: unknown }) => void> = []; + const bus: any = { + subscribe: (_channel: string, handler: (msg: { payload: unknown }) => void) => { + subscribers.push(handler); + return () => {}; + }, + publish: async (_channel: string, payload: unknown) => { + for (const handler of subscribers) handler({ payload }); + }, + }; + const writer = await boot(stores, 'control-plane'); + const peer = await boot(stores, 'control-plane'); + writer.protocol.attachMetadataMutationPubSub(bus, 'writer'); + peer.protocol.attachMetadataMutationPubSub(bus, 'peer'); + + await writer.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); + await writer.protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh') }); + await vi.waitFor(() => expect(registrySubject(peer.engine, 'zh-CN')).toBe('ADMIN zh')); + expect(registrySubject(peer.engine, 'en-US')).toBe('ADMIN en'); + + await writer.protocol.deleteMetaItem({ type: T, name: NAME, member: 'zh-CN' }); + await vi.waitFor(() => expect(registrySubject(peer.engine, 'zh-CN')).toBe('PKG zh')); + expect(registrySubject(peer.engine, 'en-US')).toBe('ADMIN en'); + }); +}); diff --git a/packages/objectql/src/registry-i18n-bundle-key.test.ts b/packages/objectql/src/registry-i18n-bundle-key.test.ts index edb1514fe0c..49dfa2d9004 100644 --- a/packages/objectql/src/registry-i18n-bundle-key.test.ts +++ b/packages/objectql/src/registry-i18n-bundle-key.test.ts @@ -231,6 +231,63 @@ describe('SchemaRegistry — i18n bundle keys (#7730)', () => { }); }); + describe('[#22877] one member by name — the bare-name read answers the canonical member', () => { + it('a bare-name read answers the canonical member when only a second locale is overlaid', () => { + // The defect: the overlay tier won before canonical-first, so a template + // customized in `zh-CN` alone answered a read that named no member. + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged en' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged zh' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay zh' }), 'name'); + + expect(registry.getItem('email_template', 'auth.welcome')?.subject).toBe('packaged en'); + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.crm')?.subject).toBe('packaged en'); + // …while the overlay tier still wins within the canonical member. + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay en' }), 'name'); + expect(registry.getItem('email_template', 'auth.welcome')?.subject).toBe('overlay en'); + }); + + it('a named member answers that member, in the same tier order, or nothing', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged en' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged zh' }), 'name', 'com.acme.crm'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); + + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay zh' }), 'name'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('overlay zh'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('packaged en'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'fr-FR')).toBeUndefined(); + // The artifact of one member, never its overlay. + expect(registry.getArtifactItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); + // A type with no members ignores the argument. + registry.registerItem('page', { name: 'home', title: 'Home' }, 'name', 'com.acme.crm'); + expect(registry.getItem('page', 'home', undefined, 'zh-CN')?.title).toBe('Home'); + }); + + it('a member-addressed heal lifts that member\'s shadow alone; naming no member keeps the group sweep', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged en' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged zh' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay en' }), 'name'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay zh' }), 'name'); + + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'zh-CN')).toBe(true); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); + + // A runtime-only member (no artifact of it): the shadow heal declines, + // and the overlay-entry retirement takes that member's entry only. + registry.registerItem('email_template', tpl('auth.welcome', 'ja-JP', { subject: 'overlay ja' }), 'name'); + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'ja-JP')).toBe(false); + expect(registry.removeOverlayEntry('email_template', 'auth.welcome', 'ja-JP')).toBe(true); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'ja-JP')).toBeUndefined(); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); + + // No member named: the whole overlay group, as before. + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay zh' }), 'name'); + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome')).toBe(true); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('packaged en'); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); + }); + }); + describe('the #7557 disabled-package gate still sees every member', () => { it('hides the whole bundle when its owning package is disabled', () => { // `listItems` filters by each item's `_packageId` (PR #7700). Re-keying diff --git a/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts index 02b684bb2a8..93320786246 100644 --- a/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts @@ -26,7 +26,11 @@ // - after a cold boot each locale still sends its own wording, and the // by-name read serves the canonical member's; // - CONTROL: a locale nobody saved keeps the package wording, and a -// single-locale re-save is still one row per member. +// single-locale re-save is still one row per member; +// - [#22877 stage 2] the by-name member selector: `?member=zh-CN` reads +// that locale's wording, `DELETE ?member=zh-CN` resets that locale alone +// and, after a cold boot, each locale serves the expected wording; a +// misspelled or misplaced selector is a located 400. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; @@ -81,8 +85,8 @@ describe('[#22877] two locales of one email template survive a cold boot (showca return sent.at(-1)?.subject; }; /** `GET /meta/email_template/:name` — the served document, unwrapped, `_`-decorations dropped. */ - const metaBody = async (): Promise<{ status: number; body: Record }> => { - const read = await call('GET', `/meta/email_template/${NAME}`); + const metaBody = async (query = ''): Promise<{ status: number; body: Record }> => { + const read = await call('GET', `/meta/email_template/${NAME}${query}`); const doc = read.json?.data ?? read.json?.item ?? read.json; const body: Record = {}; for (const [k, v] of Object.entries(((doc?.item ?? doc) ?? {}) as Record)) { @@ -163,4 +167,33 @@ describe('[#22877] two locales of one email template survive a cold boot (showca expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it'); expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文)(二):Ship it'); }, 180_000); + + it('[stage 2] one locale is read and reset by name with the member selector, and each locale serves its wording after a cold boot', async () => { + // State from the cases above: `en-US` and `zh-CN` both customized. + expect(await metaBody('?member=zh-CN')).toMatchObject({ status: 200, body: { subject: ADMIN_ZH.replace(':', '(二):') } }); + expect(await metaBody('?member=en-US')).toMatchObject({ status: 200, body: { subject: ADMIN_EN } }); + + const reset = await call('DELETE', `/meta/email_template/${NAME}?member=zh-CN`); + expect(reset.status, JSON.stringify(reset.json)).toBe(200); + expect(reset.json).toMatchObject({ success: true, reset: true }); + expect(await overlayRows()).toEqual([{ member: null, subject: ADMIN_EN }]); + // The package ships `en-US` only, so `zh-CN` now falls to the floor — + // the admin's English, untouched by the reset. + expect(await sendSubject('zh-CN')).toBe('Done (admin, English): Ship it'); + + await restart(); + expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it'); + expect(await sendSubject('zh-CN')).toBe('Done (admin, English): Ship it'); + expect(await metaBody()).toMatchObject({ status: 200, body: { subject: ADMIN_EN } }); + // Nothing stores or ships `zh-CN` now: the read answers it as absent. + expect((await metaBody('?member=zh-CN')).status).toBe(404); + + // A located 400 for a misspelled selector, and for one on a type with no members. + const misspelled = await call('GET', `/meta/email_template/${NAME}?memeber=zh-CN`); + expect(misspelled.status).toBe(400); + expect(misspelled.json?.error?.code).toBe('VALIDATION_ERROR'); + const noMembers = await call('DELETE', `/meta/view/${NAME}?member=zh-CN`); + expect(noMembers.status, JSON.stringify(noMembers.json)).toBe(400); + expect(JSON.stringify(noMembers.json)).toContain('selector names a bundle member'); + }, 180_000); }); diff --git a/packages/rest/src/rest-server-meta-member-selector.test.ts b/packages/rest/src/rest-server-meta-member-selector.test.ts new file mode 100644 index 00000000000..b08123657ca --- /dev/null +++ b/packages/rest/src/rest-server-meta-member-selector.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22877] The bundle member selector on the by-name `/meta` doors, and the + * closed query-parameter set each of the five routes declares with it + * (ruling 6116165299, Q2 i; AGENTS.md route rule 5). + * + * `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on + * `/meta/:type/:name` accept `?member=` — one member of a bundled type + * (`email_template`'s `(name, locale)`), absent meaning the canonical one. + * None of the five declared a closed set before, so a misspelled selector + * (`?memeber=zh-CN`) would have been dropped and answered with the canonical + * member: a plausible 200 about the wrong row. Each route now opens with + * recognition over its measured set. + * + * Both halves per route, as the rule asks: + * - REFUSAL: status, the NESTED `error.code`, the located message, and the + * protocol never called; + * - PRESERVATION: the request the protocol actually received, measured + * names and the member included — never merely "still 200". + * + * The VALUE of the selector is the protocol's to judge, once, for every + * transport: the value pins below drive a REAL `ObjectStackProtocolImplementation` + * over an engine that is never reached (`{}`), so a 400 here is the + * protocol's own refusal, before any store read, rendered by the route's one + * error door (`handleRouteError`) as it renders every refusal the protocol + * raises — the flat position (`body.code`, `body.error` the sentence) that + * `check:route-envelope` ratchets repo-wide, not the nested one the door's + * own recognition refusal writes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// `.js` on purpose — NodeNext resolution requires the extension. +import { + RestServer, + META_ITEM_MEMBER_PARAM, + META_ITEM_READ_PARAMS, + META_ITEM_RESET_PARAMS, + META_ITEM_HISTORY_PARAMS, + META_ITEM_PUBLISH_PARAMS, + META_ITEM_ROLLBACK_PARAMS, +} from './rest-server.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; + +const META = '/api/v1/meta'; +const ITEM = `${META}/:type/:name`; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + _headers: {} as Record, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + write: vi.fn(function (this: any) { return true; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any, k: string, v: string) { this._headers[k] = v; return this; }), + }; + return res; +} + +const TEMPLATE = { name: 'acme.welcome', label: 'Welcome', locale: 'zh-CN', subject: 'zh', bodyHtml: '

zh

' }; + +/** + * A RestServer over a stubbed protocol. `real`, when given, is a real protocol + * whose by-name doors the stub delegates to — used by the value pins, whose + * refusal happens before the protocol touches its (absent) engine. + */ +function boot(options: { enableCache?: boolean; real?: ObjectStackProtocolImplementation; delegated?: string } = {}) { + const real: any = options.real; + // Only the door under test reaches the real protocol: a door's own + // pre-reads (the history door's per-caller refusal reads the item) stay + // on the stub, so the answer is the door's verdict and nothing else's. + const delegate = (name: string, answer: unknown) => vi.fn((request: unknown) => + (real && options.delegated === name ? real[name](request) : Promise.resolve(answer))); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue({ items: [] }), + getMetaItem: delegate('getMetaItem', { type: 'email_template', name: 'acme.welcome', item: TEMPLATE, lock: 'none' }), + getMetaItemCached: vi.fn().mockResolvedValue({ data: TEMPLATE, etag: { value: 'e', weak: false } }), + getMetaItemLayered: vi.fn().mockResolvedValue({ type: 'email_template', name: 'acme.welcome', code: null, overlay: null, effective: TEMPLATE }), + deleteMetaItem: delegate('deleteMetaItem', { success: true, reset: true }), + historyMetaItem: delegate('historyMetaItem', { events: [] }), + publishMetaItem: delegate('publishMetaItem', { success: true, version: 'v', seq: 1 }), + rollbackMetaItem: delegate('rollbackMetaItem', { success: true, version: 'v', seq: 2, restoredFromVersion: 1 }), + }; + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false }, metadata: { enableCache: options.enableCache ?? false } } as any, + ); + // `isSystem` clears the capability and authoring gates that run BEFORE the + // query is read, so every request reaches the rule it is named after. + (rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' }); + rest.registerRoutes(); + + const drive = async ( + method: string, + path: string, + query: Record, + body: unknown = {}, + params: Record = { type: 'email_template', name: 'acme.welcome' }, + ) => { + const found = (rest as any).getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + await found.handler({ method, path, params, query, headers: {}, body } as any, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + return { protocol, drive }; +} + +/** The five doors: the route, its declared set, the protocol method it reaches and a body it accepts. */ +const DOORS = [ + { door: 'GET item', method: 'GET', path: ITEM, set: META_ITEM_READ_PARAMS, call: 'getMetaItem', body: {} }, + { door: 'DELETE item (reset)', method: 'DELETE', path: ITEM, set: META_ITEM_RESET_PARAMS, call: 'deleteMetaItem', body: {} }, + { door: 'GET /history', method: 'GET', path: `${ITEM}/history`, set: META_ITEM_HISTORY_PARAMS, call: 'historyMetaItem', body: {} }, + { door: 'POST /publish', method: 'POST', path: `${ITEM}/publish`, set: META_ITEM_PUBLISH_PARAMS, call: 'publishMetaItem', body: {} }, + { door: 'POST /rollback', method: 'POST', path: `${ITEM}/rollback`, set: META_ITEM_ROLLBACK_PARAMS, call: 'rollbackMetaItem', body: { toVersion: 1 } }, +] as const; + +describe('[#22877] each by-name /meta door declares a closed query set, the member selector in it', () => { + it('the selector is `member` — never `locale`, which names the response translation locale', () => { + expect(META_ITEM_MEMBER_PARAM).toBe('member'); + for (const { set } of DOORS) expect(set).toContain('member'); + // `locale` stays what it was on the item read: the translation locale. + expect(META_ITEM_READ_PARAMS).toContain('locale'); + }); + + it('the measured sets — paging, switches and the read a frame down included', () => { + expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); + expect([...META_ITEM_RESET_PARAMS].sort()).toEqual(['dropStorage', 'member', 'state']); + expect([...META_ITEM_HISTORY_PARAMS].sort()).toEqual(['limit', 'member', 'sinceSeq']); + expect([...META_ITEM_PUBLISH_PARAMS].sort()).toEqual(['member', 'package']); + expect([...META_ITEM_ROLLBACK_PARAMS].sort()).toEqual(['member', 'toVersion']); + }); + + for (const { door, method, path, set, call, body } of DOORS) { + it(`${door}: REFUSAL — a misspelled selector is a located 400 and the protocol is never called`, async () => { + const { protocol, drive } = boot(); + const answer = await drive(method, path, { memeber: 'zh-CN' }, body); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(unknownQueryParamMessage(['memeber'], [...set].sort())); + expect(protocol[call], 'the door answered before the protocol').not.toHaveBeenCalled(); + }); + + it(`${door}: REFUSAL — a repeated selector is refused, never resolved`, async () => { + const { protocol, drive } = boot(); + const answer = await drive(method, path, { member: ['zh-CN', 'ja-JP'] }, body); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"member" query parameter was supplied 2 times'); + expect(protocol[call]).not.toHaveBeenCalled(); + }); + } +}); + +describe('[#22877] PRESERVATION — the request each protocol door receives', () => { + it('GET item: the member and every measured name reach `getMetaItem`, and the cache is bypassed', async () => { + const { protocol, drive } = boot({ enableCache: true }); + const answer = await drive('GET', ITEM, { member: 'zh-CN', package: 'com.acme.mail', locale: 'zh-CN' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItemCached, 'the cached read takes no member').not.toHaveBeenCalled(); + expect(protocol.getMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', packageId: 'com.acme.mail', member: 'zh-CN', + }); + }); + + it('GET item CONTROL: no member keeps the cached arm and sends no member', async () => { + const { protocol, drive } = boot({ enableCache: true }); + const answer = await drive('GET', ITEM, { locale: 'zh-CN' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('GET item: the layered view refuses a member instead of serving the canonical member\'s layers', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', ITEM, { layers: 'true', member: 'zh-CN' }); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error).toContain('"member" selector is not served by the layered view'); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('DELETE item: the member reaches `deleteMetaItem` beside `state`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('DELETE', ITEM, { member: 'zh-CN', state: 'draft' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.deleteMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', actor: 'u1', state: 'draft', member: 'zh-CN', + }); + }); + + it('GET /history: the member reaches `historyMetaItem` beside the paging pair', async () => { + const { protocol, drive } = boot(); + const answer = await drive('GET', `${ITEM}/history`, { member: 'zh-CN', sinceSeq: '3', limit: '10' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.historyMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', sinceSeq: 3, limit: 10, member: 'zh-CN', + }); + }); + + it('POST /publish: the member reaches `publishMetaItem` beside `package`', async () => { + const { protocol, drive } = boot(); + const answer = await drive('POST', `${ITEM}/publish`, { member: 'zh-CN', package: 'com.acme.mail' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.publishMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', actor: 'u1', packageId: 'com.acme.mail', member: 'zh-CN', + }); + }); + + it('POST /rollback: the member reaches `rollbackMetaItem`, and the query `toVersion` still reads', async () => { + const { protocol, drive } = boot(); + const answer = await drive('POST', `${ITEM}/rollback`, { member: 'zh-CN', toVersion: '4' }, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.rollbackMetaItem.mock.calls[0][0]).toEqual({ + type: 'email_template', name: 'acme.welcome', toVersion: 4, actor: 'u1', member: 'zh-CN', + }); + }); + + it('CONTROL: no selector sends no `member` key on any door', async () => { + const { protocol, drive } = boot(); + for (const { method, path, call, body } of DOORS) { + const answer = await drive(method, path, {}, body); + expect(answer.status, `${call}: ${JSON.stringify(answer.body)}`).toBe(200); + expect(Object.keys(protocol[call].mock.calls[0][0]), call).not.toContain('member'); + } + }); +}); + +describe('[#22877] the VALUE is judged by the protocol, once, and rendered here as a located 400', () => { + for (const { door, method, path, call, body } of DOORS) { + it(`${door}: a selector on a type with no members — 400 VALIDATION_ERROR, before any store read`, async () => { + const { protocol, drive } = boot({ real: new ObjectStackProtocolImplementation({} as never), delegated: call }); + const answer = await drive(method, path, { member: 'zh-CN' }, body, { type: 'view', name: 'task_grid' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error).toMatch( + /^The "member" selector names a bundle member, and a 'view' item has none: its identity is its name alone\./, + ); + // Reached, and refused there: the engine (`{}`) was never read, or + // the answer would be a 500 from a missing engine method. + expect(protocol[call]).toHaveBeenCalledTimes(1); + }); + + it(`${door}: a blank selector is refused, never read as the canonical member`, async () => { + const { drive } = boot({ real: new ObjectStackProtocolImplementation({} as never), delegated: call }); + const answer = await drive(method, path, { member: ' ' }, body); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error).toMatch(/^The "member" selector is blank\./); + }); + } +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index c7b74d42ae7..fb577d15362 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -496,6 +496,7 @@ "api/DeleteManyRequest:options", "api/DeleteMetaItemRequest:actor", "api/DeleteMetaItemRequest:dropStorage", + "api/DeleteMetaItemRequest:member", "api/DeleteMetaItemRequest:name", "api/DeleteMetaItemRequest:parentVersion", "api/DeleteMetaItemRequest:state", @@ -776,6 +777,7 @@ "api/GetMetaItemLayeredResponse:provenance", "api/GetMetaItemLayeredResponse:resettable", "api/GetMetaItemLayeredResponse:type", + "api/GetMetaItemRequest:member", "api/GetMetaItemRequest:name", "api/GetMetaItemRequest:packageId", "api/GetMetaItemRequest:previewDrafts", @@ -845,6 +847,7 @@ "api/GetUiViewResponse:object", "api/GetUiViewResponse:protection", "api/HistoryMetaItemRequest:limit", + "api/HistoryMetaItemRequest:member", "api/HistoryMetaItemRequest:name", "api/HistoryMetaItemRequest:sinceSeq", "api/HistoryMetaItemRequest:type", @@ -1307,6 +1310,7 @@ "api/ProvenanceWaiver:reason", "api/ProvenanceWaiver:registeredUnder", "api/PublishMetaItemRequest:actor", + "api/PublishMetaItemRequest:member", "api/PublishMetaItemRequest:message", "api/PublishMetaItemRequest:name", "api/PublishMetaItemRequest:packageId", From 00886e17d7ec60f09f4e58a20723852e1541622b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:23:10 +0000 Subject: [PATCH 3/7] fix(metadata-protocol): the selector reads either spelling of the type (#22877) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2e0da8a8838..46e7de0d269 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2029,7 +2029,9 @@ export const BUNDLE_MEMBER_MAX_LENGTH = 100; */ export function resolveBundleMemberSelector(type: string, member: unknown): string | undefined { if (member === undefined) return undefined; - const singular = PLURAL_TO_SINGULAR[type] ?? type; + // Either spelling of the type: the doors ask after their boundary fold, + // and a direct caller may not have folded. + const singular = canonicalMetaType(type); const disc = ITEM_KEY_DISCRIMINATORS[singular]; const refuse = (sentence: string): never => { const err = new Error(`The "${BUNDLE_MEMBER_SELECTOR}" selector ${sentence}`) as Error & { @@ -2072,7 +2074,7 @@ export function resolveBundleMemberSelector(type: string, member: unknown): stri * member when none was named. `undefined` for a type with no members. */ function bundleMemberDiscriminator(type: string, member: string | undefined): string | undefined { - const disc = ITEM_KEY_DISCRIMINATORS[PLURAL_TO_SINGULAR[type] ?? type]; + const disc = ITEM_KEY_DISCRIMINATORS[canonicalMetaType(type)]; if (!disc) return undefined; return member?.trim() || disc.canonical; } From fc8d86703d4efe3d968ace6b8740034feb848b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:35:55 +0000 Subject: [PATCH 4/7] docs(spec): regenerate the protocol reference for the member selector (#22877) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 200f3cdb4f3..a428c2e39b2 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -728,6 +728,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **actor** | `string` | optional | Identity recorded on the delete's history tombstone row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. | | **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. | +| **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. | --- @@ -1345,6 +1346,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name (snake_case identifier) | | **packageId** | `string` | optional | Optional package ID to filter items by | +| **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. | | **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. | @@ -1875,6 +1877,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **name** | `string` | ✅ | Item name | | **sinceSeq** | `number` | optional | Exclusive lower bound on `seq` for pagination: only events with `seq > sinceSeq` are returned. Absent means "from the beginning". | | **limit** | `number` | optional | Maximum events to return, oldest first. Forwarded to the repository unclamped and with NO default — absent means the full remaining change log. (Deliberately no declared bounds: unlike the audit twin's [1, 500] clamp, nothing on this path clamps or refuses, so declaring `.min()`/`.max()` here would refuse values the shipped verb accepts.) | +| **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. | --- @@ -2319,6 +2322,7 @@ Installed package with runtime lifecycle state | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | +| **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. | --- From fdf01eb9e9f8d7150ab3bce80d8443e44ed8290e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:53:36 +0000 Subject: [PATCH 5/7] test(objectql): the bare-name revert CONTROL stands on its own (#22877) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...nvironment-overlay-email-locale-restart.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 7ab7826c14f..ad900339059 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -420,9 +420,17 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2) .then(() => undefined, (e: unknown) => e); expect(crossed?.code).toBe('VERSION_NOT_FOUND'); expect(crossed?.status).toBe(404); - // CONTROL: the canonical member reverts to its own version by the bare name. - expect(await protocol.rollbackMetaItem({ type: T, name: NAME, toVersion: en[0]!.version! })).toMatchObject({ success: true }); - expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh 1' }); + }); + + it('CONTROL: a revert by the bare name reverts the canonical member to its own version, and leaves zh-CN', async () => { + const stores = new Map>>(); + const { protocol } = await boot(stores); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en 1') }); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh') }); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en 2') }); + const [enFirst] = (await protocol.historyMetaItem({ type: T, name: NAME })).events; + expect(await protocol.rollbackMetaItem({ type: T, name: NAME, toVersion: enFirst!.version! })).toMatchObject({ success: true }); + expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en 1', 'zh-CN': 'ADMIN zh' }); }); it('a wrong selector is a located 400 on every door, and nothing is read or written', async () => { From 2b882e6774dda4bd54b14104da070b900fb6e5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:39:54 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix(objectql):=20a=20member's=20shadow=20is?= =?UTF-8?q?=20lifted=20when=20the=20bundle=20ships=20any=20artifact=20?= =?UTF-8?q?=E2=80=94=20a=20hydrated=20overlay=20of=20an=20unshipped=20memb?= =?UTF-8?q?er=20wears=20the=20bundle's=20envelope=20(#22877)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...nment-overlay-email-locale-restart.test.ts | 34 ++++++++++++++++++- .../src/registry-i18n-bundle-key.test.ts | 16 ++++++--- packages/objectql/src/registry.ts | 15 +++++--- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index ad900339059..901b89ccc79 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -50,6 +50,7 @@ import { ObjectQL } from './engine.js'; const PKG = 'com.acme.mail'; const NAME = 'acme.welcome'; +const DIGEST = 'acme.digest'; const template = (locale: string, subject: string): Record => ({ name: NAME, @@ -64,7 +65,17 @@ function mailManifest(): Record { return { id: PKG, name: 'acme_mail', - emailTemplates: [template('en-US', 'PKG en'), template('zh-CN', 'PKG zh')], + // A versioned package, as a real install is: its items carry the + // package's protection envelope (`_packageId`, `_provenance`), which a + // hydrated overlay of them is grafted with. + version: '1.0.0', + emailTemplates: [ + template('en-US', 'PKG en'), template('zh-CN', 'PKG zh'), + // [#22877 stage 2] A second template the package ships in `en-US` + // only, as the showcase ships its templates: a `zh-CN` overlay of + // it customizes a member no artifact ships. + { ...template('en-US', 'PKG digest en'), name: DIGEST }, + ], }; } @@ -373,6 +384,27 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2) expect(registrySubject(after.engine, 'en-US')).toBe('ADMIN en'); }); + it('DELETE by member of a member NO artifact ships: its overlay leaves the registry, and the canonical overlay stays', async () => { + // The package ships `acme.digest` in `en-US` only (the showcase's + // shape). The hydrated `zh-CN` overlay wears the bundle's artifact + // envelope, so the heal must lift it as that member's shadow; leaving + // it kept serving the reset wording until the next boot. + const stores = new Map>>(); + const first = await boot(stores); + await first.protocol.saveMetaItem({ type: T, name: DIGEST, item: { ...template('en-US', 'ADMIN digest en'), name: DIGEST } }); + await first.protocol.saveMetaItem({ type: T, name: DIGEST, item: { ...template('zh-CN', 'ADMIN digest zh'), name: DIGEST } }); + const { engine, protocol } = await boot(stores); + const digestEntry = (member: string) => engine.registry.getItem(T, DIGEST, undefined, member)?.subject; + expect(digestEntry('zh-CN')).toBe('ADMIN digest zh'); + + expect(await protocol.deleteMetaItem({ type: T, name: DIGEST, member: 'zh-CN' })).toMatchObject({ success: true, reset: true }); + expect(digestEntry('zh-CN')).toBeUndefined(); + expect(digestEntry('en-US')).toBe('ADMIN digest en'); + const listed: any = await protocol.getMetaItemsForExecution({ type: T }); + const digest = (listed?.items ?? []).filter((it: any) => it?.name === DIGEST).map((it: any) => `${it.locale}:${it.subject}`); + expect(digest).toEqual(['en-US:ADMIN digest en']); + }); + it('DELETE with no member (the reverse): the canonical member is reset and the zh-CN overlay stays registered', async () => { const stores = new Map>>(); const { engine, protocol } = await bootWithBothSaved(stores); diff --git a/packages/objectql/src/registry-i18n-bundle-key.test.ts b/packages/objectql/src/registry-i18n-bundle-key.test.ts index 49dfa2d9004..85a31086c08 100644 --- a/packages/objectql/src/registry-i18n-bundle-key.test.ts +++ b/packages/objectql/src/registry-i18n-bundle-key.test.ts @@ -272,14 +272,22 @@ describe('SchemaRegistry — i18n bundle keys (#7730)', () => { expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); - // A runtime-only member (no artifact of it): the shadow heal declines, - // and the overlay-entry retirement takes that member's entry only. + // A member the package does NOT ship: its shadow is lifted all the same + // (a packaged artifact of the name remains), and nothing else moves. registry.registerItem('email_template', tpl('auth.welcome', 'ja-JP', { subject: 'overlay ja' }), 'name'); - expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'ja-JP')).toBe(false); - expect(registry.removeOverlayEntry('email_template', 'auth.welcome', 'ja-JP')).toBe(true); + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'ja-JP')).toBe(true); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'ja-JP')).toBeUndefined(); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); + // A bundle no package ships: the shadow heal declines, and the + // overlay-entry retirement takes the named member's entry only. + registry.registerItem('email_template', tpl('crm.digest', 'en-US', { subject: 'digest en' }), 'name'); + registry.registerItem('email_template', tpl('crm.digest', 'zh-CN', { subject: 'digest zh' }), 'name'); + expect(registry.removeRuntimeShadow('email_template', 'crm.digest', 'zh-CN')).toBe(false); + expect(registry.removeOverlayEntry('email_template', 'crm.digest', 'zh-CN')).toBe(true); + expect(registry.getItem('email_template', 'crm.digest', undefined, 'zh-CN')).toBeUndefined(); + expect(registry.getItem('email_template', 'crm.digest', undefined, 'en-US')?.subject).toBe('digest en'); + // No member named: the whole overlay group, as before. registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay zh' }), 'name'); expect(registry.removeRuntimeShadow('email_template', 'auth.welcome')).toBe(true); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 18461c2d61c..b4b1c5441c9 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -4779,8 +4779,10 @@ export class SchemaRegistry { * * @param member - [#22877] For a type whose identity is a pair, the ONE * bundle member whose shadow to lift, as its discriminator value. Named, - * only that member's shadow goes, and only while the artifact still ships - * that member. Omitted, the bundle's whole shadow group, as before. + * only that member's shadow goes — whether or not the package ships that + * member, because the test is the one it always was: a packaged artifact + * of the NAME remains, so the name stays resolvable. Omitted, the bundle's + * whole shadow group, as before. */ removeRuntimeShadow(type: string, name: string, member?: string): boolean { const collection = this.metadata.get(type); @@ -4801,10 +4803,13 @@ export class SchemaRegistry { const disc = ITEM_KEY_DISCRIMINATORS[type]; if (disc) { const { bare, local, other } = collectBundle(collection, name, disc); - const narrow = (entries: BundleEntry[]) => (member === undefined ? entries : entriesOfMember(entries, disc, member)); - const shadows = narrow(bare); + const shadows = member === undefined ? bare : entriesOfMember(bare, disc, member); if (shadows.length === 0) return false; - const artifact = narrow([...local, ...other]).find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); + // The bundle's artifact, any member's: a hydrated overlay of a member + // the package does NOT ship still wears the bundle's artifact envelope + // (`_packageId`, `_provenance`), so the overlay-entry retirement that + // would otherwise take it refuses it as code — this tier must lift it. + const artifact = [...local, ...other].find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); if (!artifact) return false; for (const { key } of shadows) { collection.delete(key); From 2c055c088f2e75046eed9e17b8fa9203a38e3019 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:14:45 +0000 Subject: [PATCH 7/7] =?UTF-8?q?chore(changeset):=20objectql=20is=20minor?= =?UTF-8?q?=20=E2=80=94=20SchemaRegistry's=20public=20methods=20take=20an?= =?UTF-8?q?=20optional=20member=20(#22877)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22877-member-selector.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/22877-member-selector.md b/.changeset/22877-member-selector.md index 9557ceeaec5..bfb3c10c5f4 100644 --- a/.changeset/22877-member-selector.md +++ b/.changeset/22877-member-selector.md @@ -2,7 +2,7 @@ '@objectstack/metadata-protocol': minor '@objectstack/rest': minor '@objectstack/spec': minor -'@objectstack/objectql': patch +'@objectstack/objectql': minor --- feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) @@ -37,3 +37,13 @@ A client that sent any other parameter to these routes was having it ignored; it - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. - In a cluster, a save, reset or revert of one language converges the other servers on that language. + +**For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + +- `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. +- `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. +- The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) does not include it.