diff --git a/.changeset/22877-member-selector.md b/.changeset/22877-member-selector.md index bfb3c10c5f4..996ecbafbd2 100644 --- a/.changeset/22877-member-selector.md +++ b/.changeset/22877-member-selector.md @@ -46,4 +46,4 @@ A client that sent any other parameter to these routes was having it ignored; it - `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. +- 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`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. diff --git a/.changeset/23073-list-member.md b/.changeset/23073-list-member.md new file mode 100644 index 00000000000..dbb47bd9b8a --- /dev/null +++ b/.changeset/23073-list-member.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) + +Clause-②: yes (widening: a new declared response key) + +**What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. + +- Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. +- `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. +- The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. + +**For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 3c2533cbdee..75e7904e42b 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -184,7 +184,7 @@ const result = DiscardPackageDraftsResponseSchema.parse(data); | **aiModel** | `string` | optional | AI model that authored the change, when recorded. | | **parentCommitId** | `string` | optional | The commit this one chains from, when recorded. | | **itemCount** | `number` | ✅ | How many items the commit touched. | -| **items** | `{ type: string; name: string; existedBefore: boolean; prevVersion: number \| null }[]` | ✅ | The items the commit touched. | +| **items** | `{ type: string; name: string; member?: string; existedBefore: boolean; … }[]` | ✅ | The items the commit touched. | | **createdAt** | `string` | optional | When the commit was made (ISO-8601 string), when recorded. | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 1140d3e267e..09ae73d485c 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2108,7 +2108,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **drafts** | `{ type: string; name: string; label: string \| Record \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | +| **drafts** | `{ type: string; name: string; member?: string; label: string \| Record \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | ### Nested Shape: `ListDraftsResponse.drafts[number]` @@ -2116,6 +2116,7 @@ Install package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (canonical singular). | | **name** | `string` | ✅ | Item name. | +| **member** | `string` | optional | The bundle member this draft is, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): its discriminator value (`zh-CN`), and the canonical member spelled out (`en-US`). Present on every draft of such a type, so two members of one name are two entries; absent for a type whose identity is its name alone. Pass it back as the by-name doors' `member` selector (`?member=`) to read, publish or discard this draft. | | **label** | `string \| Record \| null` | ✅ | The draft body's own top-level `label`, as authored: a plain string or an inline locale map (`I18nLabel`), resolved by the reader. `null` when the body declares none, or none this shape admits — never the item name standing in for it. | | **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. | | **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. | diff --git a/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts b/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts index be053845a06..aa3ab667fe9 100644 --- a/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts +++ b/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts @@ -79,6 +79,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { ListPackageCommitsResponseSchema } from '@objectstack/spec/api'; /** Canonical instant text — exactly what `Date.prototype.toISOString` emits. */ const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -212,3 +213,34 @@ describe('[#14038] listCommits emits the ISO-8601 string createdAt is declared a }); }); }); + +describe('[#23073] a plan item carries the bundle member it is', () => { + it('two languages of one email_template are two items with distinct `member`, read off the stored plan; a name-only type carries no `member` key', async () => { + // The plan stores no `member` for the canonical member (absent = `en-US`) + // and the value for any other one — what `revertCommit` acts on. + const row = { + ...commitRow(SQLITE_TEXT), + item_count: 3, + items: JSON.stringify([ + { type: 'email_template', name: 'acme.welcome', existedBefore: true, prevVersion: 2 }, + { type: 'email_template', name: 'acme.welcome', existedBefore: false, prevVersion: null, member: 'zh-CN' }, + { type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 }, + ]), + }; + const p = new ObjectStackProtocolImplementation(engineWithCommits([row])); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + const items = commits[0]!.items; + expect(items).toEqual([ + { type: 'email_template', name: 'acme.welcome', member: 'en-US', existedBefore: true, prevVersion: 2 }, + { type: 'email_template', name: 'acme.welcome', member: 'zh-CN', existedBefore: false, prevVersion: null }, + { type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 }, + ]); + expect(items[2]).not.toHaveProperty('member'); + + const parsed = ListPackageCommitsResponseSchema.safeParse({ commits }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.commits).toEqual(commits); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5f29462d845..29579b1b1e5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2183,8 +2183,9 @@ function mutationEventMember(type: string, member: string | null | undefined): { * `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}). + * other item is stored byte-identical to what it was. [#23073] The commit + * timeline serves it on every item of a bundled type, the canonical member + * spelled out ({@link ObjectStackProtocolImplementation.listCommits}). */ type CommitPlanItem = { type: string; name: string; existedBefore: boolean; prevVersion: number | null; member?: string }; @@ -22922,6 +22923,13 @@ export class ObjectStackProtocolImplementation implements * only its own `label` ([#22200]), the one member a draft-only item can be * shown by other than its machine name, passed through from * {@link SysMetadataRepository.listDrafts} as the repository projects it. + * + * [#23073] `member` tells two drafts of one bundle apart (ruling + * 6116165299, letter A: each member is addressable by name). It is the + * repository header's stored member, the column the package-batch doors + * promote and discard by, with the canonical member spelled out, as the + * by-name doors' `member` selector takes it back + * ({@link bundleMemberDiscriminator}); absent for a type with no members. */ async listDrafts(request?: { packageId?: string; @@ -22930,6 +22938,8 @@ export class ObjectStackProtocolImplementation implements drafts: Array<{ type: string; name: string; + /** [#23073] The bundle member the draft is (`'zh-CN'`, the canonical `'en-US'`); absent for a type with no members. */ + member?: string; /** The draft body's own top-level `label`, as authored; `null` when it declares none. */ label: I18nLabel | null; packageId: string | null; @@ -22944,10 +22954,15 @@ export class ObjectStackProtocolImplementation implements ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), ...(request?.packageId ? { packageId: request.packageId } : {}), }); - // [#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) }; + // [#23073] The declared header (`ListDraftsResponseSchema`), with the + // row's stored member on every draft of a bundled type: absent there + // means the canonical member, which is spelled out here. + return { + drafts: drafts.map(({ member: stored, ...header }) => { + const member = bundleMemberDiscriminator(header.type, stored); + return member === undefined ? header : { ...header, member }; + }), + }; } /** @@ -25514,7 +25529,12 @@ export class ObjectStackProtocolImplementation implements aiModel?: string; parentCommitId?: string; itemCount: number; - items: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>; + /** + * [#23073] `member` — the bundle member the item is (`'zh-CN'`, the + * canonical `'en-US'`), as the stored revert plan records it; absent + * for a type with no members. + */ + items: Array<{ type: string; name: string; member?: string; existedBefore: boolean; prevVersion: number | null }>; createdAt?: string; }>> { try { @@ -25544,9 +25564,14 @@ 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, - // [#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), + // [#23073] The declared item (`ListPackageCommitsResponseSchema`), + // with the plan item's stored member — the one `revertCommit` + // acts on — on every item of a bundled type: absent there means + // the canonical member, which is spelled out here. + items: this.parseCommitItems(r.items).map(({ member: stored, ...item }) => { + const member = bundleMemberDiscriminator(item.type, stored); + return member === undefined ? item : { ...item, member }; + }), // [#14038] Canonicalise the ONE driver materialisation this // column is measured to produce (a valid JS `Date`, on // Postgres/MySQL); every other shape — including an diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts index 093f134891d..9571f2335a5 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts @@ -508,5 +508,31 @@ describe('#22200 — each listDrafts row carries the draft body\'s own label, or } expect(response.drafts[0]!.label).toBe('Account'); }); + + it('[#23073] two languages of one email_template are two drafts with distinct `member`, read off the stored column; a name-only type carries no `member` key', async () => { + // The body says `en-US` on BOTH rows: the served member is the row's + // stored `bundle_member` (NULL = the canonical member), never the body's. + const body = JSON.stringify({ name: 'acme.welcome', label: 'Welcome', locale: 'en-US' }); + const engine = makeFakeEngine([ + draftRow({ type: 'email_template', name: 'acme.welcome', metadata: body, bundle_member: null }), + draftRow({ type: 'email_template', name: 'acme.welcome', metadata: body, bundle_member: 'zh-CN' }), + draftRow({ type: 'object', name: 'account', metadata: JSON.stringify({ name: 'account' }) }), + ]); + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const response = await protocol.listDrafts(); + const templates = response.drafts.filter((d) => d.type === 'email_template'); + expect(templates.map((d) => [d.name, d.member])).toEqual([ + ['acme.welcome', 'en-US'], + ['acme.welcome', 'zh-CN'], + ]); + const account = response.drafts.find((d) => d.name === 'account')!; + expect(Object.keys(account).sort()).toEqual(HEADER_KEYS); + expect(account).not.toHaveProperty('member'); + + const parsed = ListDraftsResponseSchema.safeParse(response); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(response); + }); }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index a3d942738dd..27d806dc580 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -1514,8 +1514,9 @@ export class SysMetadataRepository implements MetadataRepository { * 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. + * promoted or discarded as the member it is. [#23073] The protocol's + * draft list serves it on every draft of a bundled type, the canonical + * member spelled out. */ member?: string; }> 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 67031179c3a..0992739f2fa 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -525,9 +525,11 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 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']); + // [#23073] The timeline serves each item's member: the publish and its revert, zh-CN both. + const commits = await protocol.listCommits({ packageId: PKG }); + expect(commits.map((c) => c.items.map((it) => [it.type, it.name, it.member]))).toEqual([ + [[T, NAME, 'zh-CN']], [[T, NAME, 'zh-CN']], + ]); }); it('discardPackageDrafts: the zh-CN draft is discarded as zh-CN', async () => { @@ -537,11 +539,36 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 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 }); + const listed = await protocol.listDrafts({ packageId: PKG }); expect(listed.drafts).toEqual([]); }); + it('[#23073] the draft list and the commit timeline tell two languages of one template apart, and each listed member addresses its own item by name', async () => { + const stores = new Map>>(); + const { protocol } = await boot(stores); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'DRAFT en'), mode: 'draft', packageId: PKG }); + await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft', packageId: PKG }); + const wording: Record = { 'en-US': 'DRAFT en', 'zh-CN': 'DRAFT zh' }; + + // Two drafts of one `type` and `name`, told apart by `member` alone. + const listed = await protocol.listDrafts({ packageId: PKG }); + expect(listed.drafts.map((d) => [d.type, d.name, d.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]); + // The listed member round-trips through the by-name door: each reads its own draft. + for (const d of listed.drafts) { + const read: any = await protocol.getMetaItem({ type: d.type, name: d.name, state: 'draft', member: d.member }); + expect(read?.item?.subject).toBe(wording[d.member!]); + } + + const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(published, JSON.stringify(published)).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); + const [commit] = await protocol.listCommits({ packageId: PKG }); + expect(commit!.items.map((it) => [it.type, it.name, it.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]); + for (const it of commit!.items) { + const read: any = await protocol.getMetaItem({ type: it.type, name: it.name, member: it.member }); + expect(read?.item?.subject).toBe(wording[it.member!]); + } + }); + 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 diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index 85f4a2a95e9..50b14eaae5b 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -109,6 +109,27 @@ describe('ListPackageCommitsResponseSchema declares the commit timeline body', ( it('the honest-empty timeline parses — {commits: []} is a declared, legal body', () => { expect(ListPackageCommitsResponseSchema.safeParse({ commits: [] }).success).toBe(true); }); + + it('[#23073] two languages of one email_template are two items told apart by `member`, which the parse keeps', () => { + const item = (member: string, existedBefore: boolean) => ({ + type: 'email_template', name: 'acme.welcome', member, existedBefore, prevVersion: existedBefore ? 2 : null, + }); + const timeline = { + commits: [{ + id: 'cmt_03', + operation: 'apply' as const, + itemCount: 3, + items: [item('en-US', true), item('zh-CN', false), realResponse.commits[0]!.items[0]!], + }], + }; + const result = ListPackageCommitsResponseSchema.safeParse(timeline); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual(timeline); + expect(result.data.commits[0]!.items.map((i) => i.member)).toEqual(['en-US', 'zh-CN', undefined]); + // A type whose identity is its name alone carries no `member` key at all. + expect(result.data.commits[0]!.items[2]).not.toHaveProperty('member'); + }); }); describe('RevertPackageCommitResponseSchema declares the revert body', () => { diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index d5cc194668a..a9e1670d777 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -79,6 +79,12 @@ export const DiscardPackageDraftsResponseSchema = lazySchema(() => z.object({ * else; this schema declares the handler's payload, wrapper included, and * that wrapper is declared as the handler's own, not the protocol's * (#12038 survey §1b rider). + * + * [#23073] An item's `member` tells two members of one bundle apart: for a + * type whose identity is a pair (`email_template`, `(name, locale)`), two + * languages of one template are two items with one `type` and `name`. It is + * the member the stored revert plan records, the one a revert of the commit + * acts on. */ export const ListPackageCommitsResponseSchema = lazySchema(() => z.object({ commits: z.array(z.object({ @@ -94,6 +100,13 @@ export const ListPackageCommitsResponseSchema = lazySchema(() => z.object({ items: z.array(z.object({ type: z.string().describe('Metadata type of the touched item.'), name: z.string().describe('Name of the touched item.'), + member: z.string().optional().describe( + 'The bundle member the touched item is, for a metadata type whose identity is a pair (today only ' + + '`email_template`, identified by `(name, locale)`): its discriminator value (`zh-CN`), and the ' + + 'canonical member spelled out (`en-US`). Present on every item of such a type, so two members of ' + + 'one name are two items; absent for a type whose identity is its name alone. It is the by-name ' + + 'doors\' `member` selector (`?member=`) for that item.', + ), existedBefore: z.boolean().describe('Whether the item existed before the commit.'), prevVersion: z.number().nullable().describe( 'The item\'s history version before the commit, `null` when it had none.', diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 350d0b7aef3..37dd7c8f0ec 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2641,6 +2641,26 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { it('the honest-empty answer parses — {drafts: []} is a declared, legal body', () => { expect(ListDraftsResponseSchema.safeParse({ drafts: [] }).success).toBe(true); }); + + it('[#23073] two languages of one email_template are two drafts told apart by `member`, which the parse keeps', () => { + const draft = (member: string) => ({ + type: 'email_template', + name: 'acme.welcome', + member, + label: 'Welcome', + packageId: 'com.acme.mail', + updatedAt: '2026-10-12T09:00:00.000Z', + updatedBy: 'admin@objectos.ai', + }); + const bundle = { drafts: [draft('en-US'), draft('zh-CN'), realResponse.drafts[0]!] }; + const result = ListDraftsResponseSchema.safeParse(bundle); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual(bundle); + expect(result.data.drafts.map((d) => d.member)).toEqual(['en-US', 'zh-CN', undefined]); + // A type whose identity is its name alone carries no `member` key at all. + expect(result.data.drafts[2]).not.toHaveProperty('member'); + }); }); describe('GetMetaDiagnosticsResponseSchema declares the validation-sweep body', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index fda0d0f3e90..6e2840b1dde 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1750,11 +1750,24 @@ export const GetPublishedMetaItemResponseSchema = lazySchema(() => z.unknown().d * `null` is the honest answer for a body that declares none; ⛔ never a * machine-name fallback, which would make "has a label" and "has none" * indistinguishable to the reader that has to choose between them. + * + * [#23073] `member` tells two drafts of one bundle apart: for a type whose + * identity is a pair (`email_template`, `(name, locale)`), two languages of + * one template are two drafts with one `type` and `name`. It is the row's + * stored member, never read off the body, and it is the value the by-name + * doors' `member` selector takes back. */ export const ListDraftsResponseSchema = lazySchema(() => z.object({ drafts: z.array(z.object({ type: z.string().describe('Metadata type name (canonical singular).'), name: z.string().describe('Item name.'), + member: z.string().optional().describe( + 'The bundle member this draft is, for a metadata type whose identity is a pair (today only ' + + '`email_template`, identified by `(name, locale)`): its discriminator value (`zh-CN`), and the ' + + 'canonical member spelled out (`en-US`). Present on every draft of such a type, so two members of ' + + 'one name are two entries; absent for a type whose identity is its name alone. Pass it back as the ' + + 'by-name doors\' `member` selector (`?member=`) to read, publish or discard this draft.', + ), label: I18nLabelSchema.nullable().describe( 'The draft body\'s own top-level `label`, as authored: a plain string or an inline ' + 'locale map (`I18nLabel`), resolved by the reader. `null` when the body declares '