Skip to content

Commit 252a582

Browse files
feat(spec, metadata-protocol): the draft list and the commit timeline tell two languages of one email template apart — each entry carries member (#23073) (#23099)
Fixes #23073 Clause-②: yes (widening: a new declared response key) Completes ruling 6116165299 (letter A) on #22877 for the two list doors: each bundle member is addressable by name, and now each list entry says which member it is. Stage 2 (PR #23070, `038d4c958`) gave the by-name `/meta` doors the `member` selector; the lists still answered two languages of one `email_template` as two entries with the same `type` and `name`, so a reader could not tell them apart or address the second one. ## What changed ### Contract (`@objectstack/spec`, `domain:spec`) - `ListDraftsResponseSchema` (`GET /meta/_drafts`): each draft entry gains `member: z.string().optional()`. - `ListPackageCommitsResponseSchema` (`GET /packages/:id/commits`): each commit item gains `member: z.string().optional()`. - Both describes say the same thing: the bundle member the entry is, for a type whose identity is a pair (today only `email_template`, `(name, locale)`); its discriminator value (`zh-CN`), with the canonical member spelled out (`en-US`); present on every entry of such a type; absent for a type whose identity is its name alone; it is the by-name doors' `member` selector (`?member=`) for that entry. - Regenerated: `content/docs/references/api/protocol.mdx` and `content/docs/references/api/package-lifecycle.mdx` (`check:generated` proved only `check:docs` stale; `--fix` regenerated that one and re-checked it green). No `authorable-surface`, `api-surface` or `json-schema` artifact moved: these are response shapes, and no export was added. ### Serving (`@objectstack/metadata-protocol`, `domain:engine`, cross-domain exception per triage 6129686435) - `listDrafts`: serves the repository header's stored `member` (`SysMetadataRepository.listDrafts`, the `bundle_member` column through `storedRowMemberHeader`), which it used to strip. - `listCommits`: serves the stored revert-plan item's `member` (`CommitPlanItem`, the member `revertCommit` acts on), which it used to strip. - Both go through `bundleMemberDiscriminator(type, storedMember)`, the stage-2 function every by-name door already uses to turn a selector into a discriminator value. It is keyed on `ITEM_KEY_DISCRIMINATORS`, the one declaration of which types are discriminated. There is no second list of types, and nothing is read off the body. - Stored, the canonical member is NULL (the column) or an absent key (the plan). This is the ruling's "NULL means the canonical member". The list spells it out as `en-US` rather than omitting it. The reasons: - Key presence then answers "is this type discriminated" for every entry: triage's direction is "carry `member` for a discriminated type, and nothing for a name-only type". - Two languages read as two distinct values, not one value beside an absence. - `?member=en-US` is the same request as no selector (stage 2's `resolveBundleMemberSelector`), so the spelled-out value round-trips through every by-name door. - Only these two serving regions of `protocol.ts` changed, plus one comment-only docblock (`CommitPlanItem`, near L2093) whose sentence "the commit timeline serves the declared four keys only" this PR makes false. The three open PRs editing `protocol.ts` (#23085 landed since; #23075, #22900) are hunk-disjoint from these. ## Premises measured on `origin/main` (`a22e67b3c`) - The repository draft header carries `member` when it is non-canonical (`storedRowMemberHeader`, `sys-metadata-repository.ts`), and the protocol stripped it: `drafts.map(({ member: _member, ...header }) => header)`. **Holds.** - The stored plan item carries `member` when it is non-canonical (`CommitPlanItem`), and `listCommits` stripped it the same way. **Holds.** - Re-declarations of either list shape, by grep over `packages/**/src` (excluding `dist`): - `@objectstack/client`: `meta.listDrafts` and `packages.listCommits` return `ListDraftsResponse` / `ListPackageCommitsResponse` imported from `@objectstack/spec/api`. This is derived, not a re-declaration, so it widens with the schema. Its `return-type-precision.test.ts` pins `toEqualTypeOf` against those same imported types. - `@objectstack/rest`: `GET /meta/_drafts` answers `res.json(result)`, a pass-through. The route ledger names the schema by string. - `@objectstack/runtime`: the `/metadata/_drafts` and `/packages/:id/commits` dispatcher arms pass the protocol answer through (`success({ commits })`). The route ledger names the schemas by string. - `@objectstack/cli`: zero reads of either list. - Result: **no re-declaration exists**, so none was updated. - The one discriminated-type predicate: stage 2's `resolveBundleMemberSelector` and `bundleMemberDiscriminator` both read `ITEM_KEY_DISCRIMINATORS` (`@objectstack/metadata-core`). This PR reuses `bundleMemberDiscriminator`. ## Pins - `packages/objectql/src/environment-overlay-email-locale-restart.test.ts` (stage 1/2's real-engine harness, real `ObjectQL` and protocol): - New: `en-US` and `zh-CN` drafts of one template, bound to a package, read from `listDrafts` as two entries `[email_template, acme.welcome, en-US]` and `[…, zh-CN]`. Each listed `member` is handed back to the by-name door (`getMetaItem({ state: 'draft', member })`) and reads its own draft's wording. After `publishPackageDrafts`, `listCommits` reads the commit's two items with the same two members, and each item's `member` reads its own published wording through `getMetaItem`. - Inverted: stage 2's pin that a commit item carries no `member` key now asserts `zh-CN` on both the publish commit and its revert. The stage-2 comment "the public draft list keeps its declared header (no `member`)" is removed, and its assertion (`drafts` empty after a discard) is kept. - `packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts` §D: two `email_template` draft rows (`bundle_member` NULL and `zh-CN`) whose bodies BOTH say `en-US`, so the served member is shown to come from the stored column, not the body. They read as `en-US` / `zh-CN`. An `object` draft's keys are exactly the six declared ones, with no `member` key. The response parses as `ListDraftsResponseSchema` with every member preserved. - `packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts`: a stored plan of an `email_template` item without `member`, one with `member: 'zh-CN'`, and an `object` item serves `en-US`, `zh-CN`, and no key. It parses as `ListPackageCommitsResponseSchema` with every member preserved. - `packages/spec/src/api/protocol.test.ts` and `package-lifecycle.test.ts`: two members of one template plus a name-only entry parse, the parse keeps `member`, and the name-only entry has no `member` property. ## Reverse verification (ablation) One-off, from the committed fix at `fe5c3a5e3`, through `scripts/ablation-replace.mjs`. In each leg the anchor hit exactly once and the blob changed. Each restore was proven by `blob == HEAD` and an empty `git diff HEAD`, and the final tree diff was 0 lines. Predicted direction for each leg: red, exactly the new case. | leg | mutation | result | |:---|:---|:---| | A1 | `listDrafts` serves `header` (no member) | `sys-metadata-repository-14938…`: `1 failed \| 18 passed (19)`: the #23073 case | | A2 | `listCommits` serves `item` (no member) | `protocol-14038…`: `1 failed \| 4 passed (5)`: the #23073 case | | B1 | `ListDraftsResponseSchema` key renamed away from `member` | spec `protocol.test.ts`: `1 failed \| 184 passed (185)`: the #23073 case | | B2 | commit-item key renamed away from `member` | spec `package-lifecycle.test.ts`: `1 failed \| 19 passed (20)`: the #23073 case | The objectql harness leg was not ablated. It reads `@objectstack/metadata-protocol` from `dist` (`KNOWN_UNALIASED_TEST_IMPORTS`), so an ablation there needs a rebuild per leg. The serving it exercises is the code that A1 and A2 ablate at source level. ## Tests and gates (each reading names the tree it was taken on) Targeted pins and the ablation ran at `fe5c3a5e3`. The merge that followed, to `f735cffa6`, brought only `packages/rest`, `packages/runtime` and two changesets, none of which these packages depend on. - **Targeted:** spec `protocol.test.ts` and `package-lifecycle.test.ts`: `Test Files 2 passed (2)`, `Tests 205 passed (205)`. metadata-protocol's two edited files: `2 passed`, `24 passed`. The objectql harness: `1 passed`, `20 passed`. - **`@objectstack/spec`:** `typecheck` (tsc, scripts and the test program) exit 0, at `fe5c3a5e3`. `check:generated` proved only `check:docs` stale. `--fix` regenerated it and re-checked it green, and the other 13 artifacts were green. - **`@objectstack/metadata-protocol`:** `typecheck` exit 0. Its `tsc` program includes both edited test files (`--listFiles`: 2 of 2). The full `test` ran at `fe5c3a5e3`: `Test Files 228 passed | 3 skipped (231)`, `Tests 28207 passed | 19 skipped (28226)`. - **`@objectstack/objectql`:** `typecheck` exit 0, `check:test-typecheck` ledger held, at `fe5c3a5e3`. The full `test` ran at `f735cffa6`: `Test Files 408 passed (408)`, `Tests 7975 passed (7975)`. - **`@objectstack/client`** (consumer: its two list methods return the spec types), at `f735cffa6`: - `tsc --noEmit` (build program): exit 0. - `return-type-precision.test.ts`, `unwrap-misfire.pin.test.ts` and `meta-automation-descriptors.test.ts`: `3 passed`, `27 passed`. - The test program (`tsconfig.test.json`) is a narrowed reading. 0 errors fall in those three files. All 35 errors elsewhere are `TS2307` cannot-find-module for unbuilt devDependency `dist` (`@objectstack/runtime`, `objectql`, `plugin-hono-server`, …). So the whole-program verdict is NOT MEASURED here and is left to CI. - **`@objectstack/rest`, `@objectstack/runtime`:** pass-throughs with no re-declaration (see the premises section). No test owed: the public byte surface they touch is the spec type, already read above. - **`@objectstack/cli`:** zero reads of either list, so no `unit` run is owed. - **Lint, targeted:** `eslint --no-inline-config --format json` over the 9 changed `.ts` files: `files_linted=9 errors=0 warnings=0`. - All 9 files fall in the config's own `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` object; the json lists all 9, so none is ignored. - The narrowing excludes nothing untouched: the config enables no type-aware linting (no `parserOptions.project`, stated in `eslint.config.mjs`), and its plugins are per-file rules. A diff cannot move an untouched file's verdict. - **Derived gate families,** at `f735cffa6`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 114, and each ran with its exit code recorded before any pipe. `--ran` printed: `114 derived famil(ies) accounted for — 113 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - Three first runs refused their prerequisite, and each was re-run at the same head after its prerequisite was met. All three re-runs exited 0: - `check-engine-split-ratio`: shallow clone. Re-run after `git fetch --shallow-since=2026-07-07 origin main`. - `check:skill-examples`: needs the client and client-react `dist`. Re-run after building them. - `check:lean-entry-closure`: needs the objectql `dist`. Re-run after building it. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET. It needs every package's `dist`, with 59+ unbuilt here, and a whole-monorepo build is CI's. ## Acceptance notes - **Same family, not fixed here (read, not measured at a door).** The package-batch result arrays name two members of one template identically too: `publishPackageDrafts`' `published[]` / `failed[]`, `discardPackageDrafts`' `discarded[]` / `failed[]`, and `revertCommit`'s `reverted[]` / `failed[]` carry `{ type, name }` only. That is the same shape this card closes for the two lists. Carrier: none. - **A pending changeset sentence this PR makes false.** `.changeset/22877-member-selector.md` (stage 2, unreleased) says "The protocol's draft list (`GET /meta/_drafts`) does not include it." That file is outside this claim's file surface, so it is not edited here. This PR's changeset says the earlier sentence is superseded. The one-line amendment belongs to the seat: delete that sentence, or replace it with "The protocol's draft list serves it too." - **Comment-only edits outside the two serving regions.** Two docblocks whose sentences this PR made false: `CommitPlanItem` in `protocol.ts` and the `member` header doc of `SysMetadataRepository.listDrafts`. - **The objectql harness** is the serving's real-engine test, and it held stage 2's opposite pin. It is edited as the serving's test. - Studio's half (`DraftChangesPanel` addressing each entry with `?member=`) is objectstack-ai/objectui#12271. --- _Generated by [Claude Code](https://claude.ai/code/session_01GZSPSQ5ArPjSJdxxuqwGoZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4a87d09 commit 252a582

13 files changed

Lines changed: 215 additions & 20 deletions

‎.changeset/22877-member-selector.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@ A client that sent any other parameter to these routes was having it ignored; it
4646
- `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.
4747
- A type whose items have no languages ignores the argument.
4848
- `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.
49-
- 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.
49+
- 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.

‎.changeset/23073-list-member.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes (widening: a new declared response key)
9+
10+
**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.
11+
12+
- 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.
13+
- `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.
14+
- 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.
15+
16+
**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.

‎content/docs/references/api/package-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ const result = DiscardPackageDraftsResponseSchema.parse(data);
184184
| **aiModel** | `string` | optional | AI model that authored the change, when recorded. |
185185
| **parentCommitId** | `string` | optional | The commit this one chains from, when recorded. |
186186
| **itemCount** | `number` | ✅ | How many items the commit touched. |
187-
| **items** | `{ type: string; name: string; existedBefore: boolean; prevVersion: number \| null }[]` | ✅ | The items the commit touched. |
187+
| **items** | `{ type: string; name: string; member?: string; existedBefore: boolean; … }[]` | ✅ | The items the commit touched. |
188188
| **createdAt** | `string` | optional | When the commit was made (ISO-8601 string), when recorded. |
189189

190190

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2108,14 +2108,15 @@ Install package response
21082108

21092109
| Property | Type | Required | Description |
21102110
| :--- | :--- | :--- | :--- |
2111-
| **drafts** | `{ type: string; name: string; label: string \| Record<string, string> \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. |
2111+
| **drafts** | `{ type: string; name: string; member?: string; label: string \| Record<string, string> \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. |
21122112

21132113
### Nested Shape: `ListDraftsResponse.drafts[number]`
21142114

21152115
| Property | Type | Required | Description |
21162116
| :--- | :--- | :--- | :--- |
21172117
| **type** | `string` | ✅ | Metadata type name (canonical singular). |
21182118
| **name** | `string` | ✅ | Item name. |
2119+
| **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. |
21192120
| **label** | `string \| Record<string, string> \| 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. |
21202121
| **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. |
21212122
| **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. |

‎packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
import { describe, it, expect, vi } from 'vitest';
8080
import { ObjectStackProtocolImplementation } from './protocol.js';
8181
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
82+
import { ListPackageCommitsResponseSchema } from '@objectstack/spec/api';
8283

8384
/** Canonical instant text — exactly what `Date.prototype.toISOString` emits. */
8485
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
212213
});
213214
});
214215
});
216+
217+
describe('[#23073] a plan item carries the bundle member it is', () => {
218+
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 () => {
219+
// The plan stores no `member` for the canonical member (absent = `en-US`)
220+
// and the value for any other one — what `revertCommit` acts on.
221+
const row = {
222+
...commitRow(SQLITE_TEXT),
223+
item_count: 3,
224+
items: JSON.stringify([
225+
{ type: 'email_template', name: 'acme.welcome', existedBefore: true, prevVersion: 2 },
226+
{ type: 'email_template', name: 'acme.welcome', existedBefore: false, prevVersion: null, member: 'zh-CN' },
227+
{ type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 },
228+
]),
229+
};
230+
const p = new ObjectStackProtocolImplementation(engineWithCommits([row]));
231+
232+
const commits = await p.listCommits({ packageId: 'pkg_crm' });
233+
234+
const items = commits[0]!.items;
235+
expect(items).toEqual([
236+
{ type: 'email_template', name: 'acme.welcome', member: 'en-US', existedBefore: true, prevVersion: 2 },
237+
{ type: 'email_template', name: 'acme.welcome', member: 'zh-CN', existedBefore: false, prevVersion: null },
238+
{ type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 },
239+
]);
240+
expect(items[2]).not.toHaveProperty('member');
241+
242+
const parsed = ListPackageCommitsResponseSchema.safeParse({ commits });
243+
expect(parsed.success).toBe(true);
244+
if (parsed.success) expect(parsed.data.commits).toEqual(commits);
245+
});
246+
});

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

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,8 +2183,9 @@ function mutationEventMember(type: string, member: string | null | undefined): {
21832183
* `sys_metadata_commit.items` stores it: `member` is the bundle member the
21842184
* item is, for a type whose identity is a pair, and is absent for the
21852185
* canonical member and every type with no members — so the plan of every
2186-
* other item is stored byte-identical to what it was. The commit timeline
2187-
* serves the declared four keys only ({@link ObjectStackProtocolImplementation.listCommits}).
2186+
* other item is stored byte-identical to what it was. [#23073] The commit
2187+
* timeline serves it on every item of a bundled type, the canonical member
2188+
* spelled out ({@link ObjectStackProtocolImplementation.listCommits}).
21882189
*/
21892190
type CommitPlanItem = { type: string; name: string; existedBefore: boolean; prevVersion: number | null; member?: string };
21902191

@@ -22922,6 +22923,13 @@ export class ObjectStackProtocolImplementation implements
2292222923
* only its own `label` ([#22200]), the one member a draft-only item can be
2292322924
* shown by other than its machine name, passed through from
2292422925
* {@link SysMetadataRepository.listDrafts} as the repository projects it.
22926+
*
22927+
* [#23073] `member` tells two drafts of one bundle apart (ruling
22928+
* 6116165299, letter A: each member is addressable by name). It is the
22929+
* repository header's stored member, the column the package-batch doors
22930+
* promote and discard by, with the canonical member spelled out, as the
22931+
* by-name doors' `member` selector takes it back
22932+
* ({@link bundleMemberDiscriminator}); absent for a type with no members.
2292522933
*/
2292622934
async listDrafts(request?: {
2292722935
packageId?: string;
@@ -22930,6 +22938,8 @@ export class ObjectStackProtocolImplementation implements
2293022938
drafts: Array<{
2293122939
type: string;
2293222940
name: string;
22941+
/** [#23073] The bundle member the draft is (`'zh-CN'`, the canonical `'en-US'`); absent for a type with no members. */
22942+
member?: string;
2293322943
/** The draft body's own top-level `label`, as authored; `null` when it declares none. */
2293422944
label: I18nLabel | null;
2293522945
packageId: string | null;
@@ -22944,10 +22954,15 @@ export class ObjectStackProtocolImplementation implements
2294422954
...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}),
2294522955
...(request?.packageId ? { packageId: request.packageId } : {}),
2294622956
});
22947-
// [#22877] The declared header (`ListDraftsResponseSchema`): the
22948-
// repository's `member` is the package-batch doors' reading, not part of
22949-
// this list's contract, and is not served on it.
22950-
return { drafts: drafts.map(({ member: _member, ...header }) => header) };
22957+
// [#23073] The declared header (`ListDraftsResponseSchema`), with the
22958+
// row's stored member on every draft of a bundled type: absent there
22959+
// means the canonical member, which is spelled out here.
22960+
return {
22961+
drafts: drafts.map(({ member: stored, ...header }) => {
22962+
const member = bundleMemberDiscriminator(header.type, stored);
22963+
return member === undefined ? header : { ...header, member };
22964+
}),
22965+
};
2295122966
}
2295222967

2295322968
/**
@@ -25514,7 +25529,12 @@ export class ObjectStackProtocolImplementation implements
2551425529
aiModel?: string;
2551525530
parentCommitId?: string;
2551625531
itemCount: number;
25517-
items: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }>;
25532+
/**
25533+
* [#23073] `member` — the bundle member the item is (`'zh-CN'`, the
25534+
* canonical `'en-US'`), as the stored revert plan records it; absent
25535+
* for a type with no members.
25536+
*/
25537+
items: Array<{ type: string; name: string; member?: string; existedBefore: boolean; prevVersion: number | null }>;
2551825538
createdAt?: string;
2551925539
}>> {
2552025540
try {
@@ -25544,9 +25564,14 @@ export class ObjectStackProtocolImplementation implements
2554425564
...(r.ai_model ? { aiModel: r.ai_model } : {}),
2554525565
...(r.parent_commit_id ? { parentCommitId: r.parent_commit_id } : {}),
2554625566
itemCount: typeof r.item_count === 'number' ? r.item_count : 0,
25547-
// [#22877] The declared item (`ListPackageCommitsResponseSchema`):
25548-
// a plan item's stored `member` is `revertCommit`'s reading.
25549-
items: this.parseCommitItems(r.items).map(({ member: _member, ...item }) => item),
25567+
// [#23073] The declared item (`ListPackageCommitsResponseSchema`),
25568+
// with the plan item's stored member — the one `revertCommit`
25569+
// acts on — on every item of a bundled type: absent there means
25570+
// the canonical member, which is spelled out here.
25571+
items: this.parseCommitItems(r.items).map(({ member: stored, ...item }) => {
25572+
const member = bundleMemberDiscriminator(item.type, stored);
25573+
return member === undefined ? item : { ...item, member };
25574+
}),
2555025575
// [#14038] Canonicalise the ONE driver materialisation this
2555125576
// column is measured to produce (a valid JS `Date`, on
2555225577
// Postgres/MySQL); every other shape — including an

‎packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,5 +508,31 @@ describe('#22200 — each listDrafts row carries the draft body\'s own label, or
508508
}
509509
expect(response.drafts[0]!.label).toBe('Account');
510510
});
511+
512+
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 () => {
513+
// The body says `en-US` on BOTH rows: the served member is the row's
514+
// stored `bundle_member` (NULL = the canonical member), never the body's.
515+
const body = JSON.stringify({ name: 'acme.welcome', label: 'Welcome', locale: 'en-US' });
516+
const engine = makeFakeEngine([
517+
draftRow({ type: 'email_template', name: 'acme.welcome', metadata: body, bundle_member: null }),
518+
draftRow({ type: 'email_template', name: 'acme.welcome', metadata: body, bundle_member: 'zh-CN' }),
519+
draftRow({ type: 'object', name: 'account', metadata: JSON.stringify({ name: 'account' }) }),
520+
]);
521+
const protocol = new ObjectStackProtocolImplementation(engine as never);
522+
523+
const response = await protocol.listDrafts();
524+
const templates = response.drafts.filter((d) => d.type === 'email_template');
525+
expect(templates.map((d) => [d.name, d.member])).toEqual([
526+
['acme.welcome', 'en-US'],
527+
['acme.welcome', 'zh-CN'],
528+
]);
529+
const account = response.drafts.find((d) => d.name === 'account')!;
530+
expect(Object.keys(account).sort()).toEqual(HEADER_KEYS);
531+
expect(account).not.toHaveProperty('member');
532+
533+
const parsed = ListDraftsResponseSchema.safeParse(response);
534+
expect(parsed.success).toBe(true);
535+
if (parsed.success) expect(parsed.data).toEqual(response);
536+
});
511537
});
512538
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,8 +1514,9 @@ export class SysMetadataRepository implements MetadataRepository {
15141514
* identity is a pair (`'zh-CN'` of an `email_template`) — its stored
15151515
* `bundle_member`. Absent for the canonical member and for every type
15161516
* with no members. The package-batch doors read it so each draft is
1517-
* promoted or discarded as the member it is; it is a header of THIS
1518-
* repository, and the protocol's draft list does not serve it.
1517+
* promoted or discarded as the member it is. [#23073] The protocol's
1518+
* draft list serves it on every draft of a bundled type, the canonical
1519+
* member spelled out.
15191520
*/
15201521
member?: string;
15211522
}>

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,11 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228
525525
expect(reverted, JSON.stringify(reverted)).toMatchObject({ success: true, revertedCount: 1 });
526526
expect(overlayRows(stores).map((r) => r.bundle_member ?? null)).toEqual([null]);
527527
expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' });
528-
// The timeline serves the declared item, with no `member` key.
529-
const commits: any[] = await (protocol as any).listCommits({ packageId: PKG });
530-
for (const c of commits) for (const it of c.items) expect(Object.keys(it).sort()).toEqual(['existedBefore', 'name', 'prevVersion', 'type']);
528+
// [#23073] The timeline serves each item's member: the publish and its revert, zh-CN both.
529+
const commits = await protocol.listCommits({ packageId: PKG });
530+
expect(commits.map((c) => c.items.map((it) => [it.type, it.name, it.member]))).toEqual([
531+
[[T, NAME, 'zh-CN']], [[T, NAME, 'zh-CN']],
532+
]);
531533
});
532534

533535
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
537539
expect(out, JSON.stringify(out)).toMatchObject({ success: true, discardedCount: 1, failedCount: 0 });
538540
expect(draftRows(stores)).toEqual([]);
539541
expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' });
540-
// The public draft list keeps its declared header (no `member`).
541-
const listed: any = await protocol.listDrafts({ packageId: PKG });
542+
const listed = await protocol.listDrafts({ packageId: PKG });
542543
expect(listed.drafts).toEqual([]);
543544
});
544545

546+
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 () => {
547+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
548+
const { protocol } = await boot(stores);
549+
await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'DRAFT en'), mode: 'draft', packageId: PKG });
550+
await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft', packageId: PKG });
551+
const wording: Record<string, string> = { 'en-US': 'DRAFT en', 'zh-CN': 'DRAFT zh' };
552+
553+
// Two drafts of one `type` and `name`, told apart by `member` alone.
554+
const listed = await protocol.listDrafts({ packageId: PKG });
555+
expect(listed.drafts.map((d) => [d.type, d.name, d.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]);
556+
// The listed member round-trips through the by-name door: each reads its own draft.
557+
for (const d of listed.drafts) {
558+
const read: any = await protocol.getMetaItem({ type: d.type, name: d.name, state: 'draft', member: d.member });
559+
expect(read?.item?.subject).toBe(wording[d.member!]);
560+
}
561+
562+
const published: any = await protocol.publishPackageDrafts({ packageId: PKG });
563+
expect(published, JSON.stringify(published)).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
564+
const [commit] = await protocol.listCommits({ packageId: PKG });
565+
expect(commit!.items.map((it) => [it.type, it.name, it.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]);
566+
for (const it of commit!.items) {
567+
const read: any = await protocol.getMetaItem({ type: it.type, name: it.name, member: it.member });
568+
expect(read?.item?.subject).toBe(wording[it.member!]);
569+
}
570+
});
571+
545572
it('the cluster fan-out: a peer converges on the member a write changed, and a member reset heals only that member', async () => {
546573
// Control-plane replicas: their registry takes the write-through a
547574
// converging peer re-runs (an environment kernel keeps a non-object

0 commit comments

Comments
 (0)