Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/22877-member-selector.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/rest': minor
Expand Down Expand Up @@ -46,4 +46,4 @@
- `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.
16 changes: 16 additions & 0 deletions .changeset/23073-list-member.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2108,14 +2108,15 @@ Install package response

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

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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<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. |
| **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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;
Expand Down Expand Up @@ -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);
});
});
45 changes: 35 additions & 10 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 };
}),
};
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
5 changes: 3 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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<string, Map<string, Record<string, unknown>>>();
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<string, string> = { '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
Expand Down
Loading
Loading