Skip to content
Merged
49 changes: 49 additions & 0 deletions .changeset/22877-member-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/rest': minor
'@objectstack/spec': minor
'@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)

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.

**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.
4 changes: 4 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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. |


---
Expand Down Expand Up @@ -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=<id>` 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. |


---
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,16 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
* envelope never reaches an answer whose body came from another package.
*/
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
{ 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)' },
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: '[#22877] a bundled type\'s canonical member when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
why: '[#22877] a bundled type\'s addressed member (the selector\'s, else the canonical one) when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' },
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' },
{ member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no',
Expand Down
Loading
Loading