Skip to content
14 changes: 14 additions & 0 deletions .changeset/22200-drafts-header-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": minor
---

feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null`

Clause-②: yes (widening)

- **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician").
- **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`).
- **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent.
- **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header.
- **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes.
3 changes: 2 additions & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2087,14 +2087,15 @@ Install package response

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **drafts** | `{ type: string; name: string; organizationId: string \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. |
| **drafts** | `{ type: string; name: string; label: string \| Record<string, string> \| null; organizationId: 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. |
| **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. |
| **organizationId** | `string \| null` | ✅ | Owning organization of the draft row, `null` for an environment-wide draft. |
| **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
12 changes: 9 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22831,7 +22831,10 @@ export class ObjectStackProtocolImplementation implements
* by `packageId` and/or `type`. The list reads of `getMetaItems` only see
* the ACTIVE registry; this exposes what an AI authored but a human hasn't
* published yet, so the console can show a "pending changes" surface and a
* just-built app package isn't displayed as empty. No body is returned.
* just-built app package isn't displayed as empty. No body is returned —
* 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.
*/
async listDrafts(request?: {
packageId?: string;
Expand All @@ -22841,6 +22844,8 @@ export class ObjectStackProtocolImplementation implements
drafts: Array<{
type: string;
name: string;
/** The draft body's own top-level `label`, as authored; `null` when it declares none. */
label: I18nLabel | null;
organizationId: string | null;
packageId: string | null;
updatedAt: string | null;
Expand Down Expand Up @@ -22907,8 +22912,9 @@ export class ObjectStackProtocolImplementation implements
* ## Why the batch's existing enumeration cannot supply the bodies
*
* `listDrafts` — the read that DEFINES this batch — is a declared header
* projection: it maps rows to `(type, name, organizationId, packageId,
* updatedAt, updatedBy)` and drops `metadata` on purpose, because its other
* projection: it maps rows to `(type, name, label, organizationId,
* packageId, updatedAt, updatedBy)` and drops `metadata` on purpose — of the
* body only its own top-level `label` leaves ([#22200]) — because its other
* caller is the console's "pending changes" list. Widening it would put
* every draft BODY on that listing, and it would not even remove the guard
* below: the doubles that lack `repo.get` stub `listDrafts` too, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ import {
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
} from '@objectstack/metadata-core';
import { ListDraftsResponseSchema } from '@objectstack/spec/api';
import { I18nLabelSchema } from '@objectstack/spec/ui';
import { SysMetadataRepository } from './sys-metadata-repository.js';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface Row {
[k: string]: unknown;
Expand Down Expand Up @@ -107,6 +110,8 @@ const INVALID_INSTANT = new Date(NaN);
const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } = {
type: (v) => typeof v === 'string',
name: (v) => typeof v === 'string',
// [#22200] The draft body's own label: an `I18nLabel` as authored, or `null`.
label: (v) => v === null || I18nLabelSchema.safeParse(v).success,
organizationId: (v) => v === null || typeof v === 'string',
packageId: (v) => v === null || typeof v === 'string',
updatedAt: (v) => v === null || typeof v === 'string',
Expand Down Expand Up @@ -346,3 +351,165 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever
});
});
});

/**
* [#22200] The `_drafts` header carries the draft body's own `label`.
*
* `GET /meta/_drafts` is the only list of draft-only items a client has, so a
* header without a label leaves such an item showable by its machine name
* alone — a permission set just created as a draft read `technician`, not
* "Technician". The label is read off the row `listDrafts` already holds (no
* second query), carried as authored, and `null` when the body declares none:
* ⛔ never the item name standing in for it.
*
* Shares this file's engine double rather than pinning a second one: the
* projection under test is the same `listDrafts` map, and the conformance
* table above now covers `label` on every case in the file.
*/
describe('#22200 — each listDrafts row carries the draft body\'s own label, or null', () => {
const HEADER_KEYS = ['label', 'name', 'organizationId', 'packageId', 'type', 'updatedAt', 'updatedBy'];

describe('§A drafts saved through the real write path', () => {
it('a draft whose body declares a label returns it', async () => {
const engine = makeFakeEngine();
const repo = makeRepo(engine);
await repo.put(
{ org: 'env', type: 'view', name: 'repair_ticket_board' } as never,
{ name: 'repair_ticket_board', label: 'Repair Ticket Board' },
{ parentVersion: null, actor: 'usr_1', state: 'draft' } as never,
);
// Non-vacuity: the writer stored a DRAFT row whose body is serialized
// text, the shape the read below has to parse.
expect(engine.rows).toHaveLength(1);
expect(engine.rows[0]!.state).toBe('draft');
expect(typeof engine.rows[0]!.metadata).toBe('string');

const drafts = await repo.listDrafts();
expect(drafts).toHaveLength(1);
expect(drafts[0]!.label).toBe('Repair Ticket Board');
expectConformsToDeclaration(drafts);
});

it('a draft whose body declares none reads null — not its machine name', async () => {
const engine = makeFakeEngine();
const repo = makeRepo(engine);
await repo.put(
{ org: 'env', type: 'view', name: 'repair_ticket_board' } as never,
{ name: 'repair_ticket_board' },
{ parentVersion: null, actor: 'usr_1', state: 'draft' } as never,
);

const drafts = await repo.listDrafts();
expect(drafts).toHaveLength(1);
expect(drafts[0]!.label).toBeNull();
expect(drafts[0]!.label).not.toBe(drafts[0]!.name);
expectConformsToDeclaration(drafts);
});
});

describe('§B the label is carried AS AUTHORED', () => {
it('an inline locale map (the `I18nLabel` form) is carried verbatim, not resolved to one locale', async () => {
// The route takes no locale, so resolving here would be the producer
// choosing a language for the reader.
const map = { en: 'Field Service', 'zh-CN': '现场服务' };
const engine = makeFakeEngine([
draftRow({ type: 'app', name: 'field_service', metadata: JSON.stringify({ name: 'field_service', label: map }) }),
]);
const repo = makeRepo(engine);

const drafts = await repo.listDrafts();
expect(drafts[0]!.label).toEqual(map);
expectConformsToDeclaration(drafts);
});

it('reads a body the driver already materialised as an object (a JSON-column dialect)', async () => {
const engine = makeFakeEngine([draftRow({ metadata: { label: 'Cases' } })]);
const repo = makeRepo(engine);

expect(typeof engine.rows[0]!.metadata).toBe('object');

const drafts = await repo.listDrafts();
expect(drafts[0]!.label).toBe('Cases');
expectConformsToDeclaration(drafts);
});
});

describe('§C null is the declared "no label", never an invented one', () => {
it.each([
['a number', 42],
['the retired key-reference form', { key: 'views.case_grid.label', defaultValue: 'Cases' }],
['an array', ['Cases']],
])('a stored label the contract cannot carry (%s) reads null, and the draft is still listed', async (_shape, label) => {
const engine = makeFakeEngine([draftRow({ metadata: JSON.stringify({ label }) })]);
const repo = makeRepo(engine);

// Non-vacuity: the stored body really carries a `label` key.
expect(JSON.parse(engine.rows[0]!.metadata as string)).toHaveProperty('label');

const drafts = await repo.listDrafts();
expect(drafts).toHaveLength(1);
expect(drafts[0]!.label).toBeNull();
expectConformsToDeclaration(drafts);
});

it('stored bytes that do not parse read null rather than failing the whole listing', async () => {
const engine = makeFakeEngine([
draftRow({ name: 'torn_grid', metadata: '{"label":"Torn' }),
draftRow({ name: 'lead_grid', metadata: '{"label":"Leads"}' }),
]);
const repo = makeRepo(engine);

expect(() => JSON.parse(engine.rows[0]!.metadata as string)).toThrow();

const drafts = await repo.listDrafts();
expect(drafts.map((d) => [d.name, d.label]).sort()).toEqual([
['lead_grid', 'Leads'],
['torn_grid', null],
]);
expectConformsToDeclaration(drafts);
});
});

describe('§D the protocol passes it through, and the spec schema agrees', () => {
it('ObjectStackProtocolImplementation.listDrafts serves the label, and the response parses as ListDraftsResponseSchema, every member preserved', async () => {
const engine = makeFakeEngine([
draftRow({ type: 'permission', name: 'technician', metadata: JSON.stringify({ name: 'technician', label: 'Technician' }) }),
draftRow({ type: 'object', name: 'repairs_repair_ticket', metadata: JSON.stringify({ name: 'repairs_repair_ticket' }) }),
]);
const protocol = new ObjectStackProtocolImplementation(engine as never);

const response = await protocol.listDrafts();
const byName = Object.fromEntries(response.drafts.map((d) => [d.name, d]));
expect(byName.technician!.label).toBe('Technician');
expect(byName.repairs_repair_ticket!.label).toBeNull();

const parsed = ListDraftsResponseSchema.safeParse(response);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data).toEqual(response);
});

it('the label is the ONLY member read off the body — the header keys are exactly seven (#6599)', async () => {
const engine = makeFakeEngine([
draftRow({
type: 'object',
name: 'account',
metadata: JSON.stringify({
name: 'account',
label: 'Account',
description: 'internal pricing notes',
fields: { salary_grade: { type: 'select', label: 'Salary Grade' } },
}),
}),
]);
const protocol = new ObjectStackProtocolImplementation(engine as never);

const response = await protocol.listDrafts();
expect(Object.keys(response.drafts[0]!).sort()).toEqual(HEADER_KEYS);
const wire = JSON.stringify(response);
for (const secret of ['internal pricing notes', 'salary_grade', 'Salary Grade', 'fields']) {
expect(wire).not.toContain(secret);
}
expect(response.drafts[0]!.label).toBe('Account');
});
});
});
44 changes: 44 additions & 0 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import type {
} from '@objectstack/metadata-core';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import { I18nLabelSchema, type I18nLabel } from '@objectstack/spec/ui';
import type { IObjectQLEngine } from '@objectstack/core';
// [#7682] The read-only-package predicate, imported rather than re-spelled —
// the same function `saveMetaItem`'s ADR-0070 D1 gate and the `/packages`
Expand Down Expand Up @@ -195,6 +196,41 @@ function storedRowBody(row: any): Record<string, unknown> {
return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {});
}

/**
* [#22200] A draft row's own top-level `label`, for the `_drafts` header
* ({@link SysMetadataRepository.listDrafts}) — the ONE member that header
* reads off the stored body. The header stays a disclosure boundary (#6599):
* the label leaves, the body never does.
*
* Carried AS AUTHORED. Every registered metadata type that declares a display
* label spells it top-level `label`, as a plain string or an `I18nLabel` inline
* locale map; the route takes no locale to resolve a map with, so the reader
* resolves it. No ADR-0087 conversion rewrites a top-level `label`, so the
* stored spelling is already the canonical one.
*
* `null` — the declared "no label" — in three cases, and ⛔ never the item
* name, which would make "has a label" and "has none" one answer:
* - the body declares none;
* - it declares one `I18nLabelSchema` refuses (a row stored before its
* type's schema was enforced on save, or a row of a type with no
* registered schema). The declared field cannot carry it, and serving it
* would hand the reader a shape its own type rules out;
* - its stored bytes do not parse. {@link SysMetadataRepository.lockHead}
* answers the same bytes the same way, for the same reason: a header
* listing must not turn into a parse failure. The draft stays listed — and
* so discardable — while every read of its body still fails loudly.
*/
function draftBodyLabel(row: any): I18nLabel | null {
let body: Record<string, unknown> | null;
try {
body = storedRowBody(row);
} catch {
return null;
}
const parsed = I18nLabelSchema.safeParse(body?.label);
return parsed.success ? parsed.data : null;
}

/**
* Overlay-row lifecycle state.
*
Expand Down Expand Up @@ -1318,6 +1354,12 @@ export class SysMetadataRepository implements MetadataRepository {
Array<{
type: string;
name: string;
/**
* [#22200] The draft body's own top-level `label`, as authored — `null`
* when it declares none (see {@link draftBodyLabel}). For a draft-only
* item this header is the only place a client can find a label at all.
*/
label: I18nLabel | null;
/**
* The scope the draft actually lives in — `null` for an env-wide draft,
* a string for a per-org overlay draft. The `$or` below surfaces BOTH to
Expand All @@ -1337,6 +1379,8 @@ export class SysMetadataRepository implements MetadataRepository {
return (rows as any[]).map((row) => ({
type: row.type,
name: row.name,
// [#22200] Off the row this read already holds — no second query.
label: draftBodyLabel(row),
organizationId: row.organization_id ?? null,
packageId: row.package_id ?? null,
// [commit c383352cb] `updated_at` / `created_at` are the BUILTIN audit columns,
Expand Down
Loading
Loading