Skip to content

Commit c512c25

Browse files
feat(spec, metadata-protocol): each _drafts row carries the draft body's own label, or null (#22323)
Fixes #22200 Clause-②: yes (widening: a new field on a published response schema) ## What changes Each row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, `client.meta.listDrafts()`) now carries `label`: the draft body's own top-level `label`, as authored, or `null` when the body declares none. It is never the machine name standing in for a missing label. - **Contract** (`packages/spec/src/api/protocol.zod.ts`): `ListDraftsResponseSchema` rows gain `label: I18nLabelSchema.nullable()`, required on the wire, with a describe that says it is the body's own label and never a name fallback. - **Producer** (`packages/metadata-protocol/src/sys-metadata-repository.ts`): `SysMetadataRepository.listDrafts` reads the label off the `sys_metadata` row it already fetches (`draftBodyLabel`), so there is no second query. The declared return gains `label: I18nLabel | null`. - **Pass-through** (`packages/metadata-protocol/src/protocol.ts`): `ObjectStackProtocolImplementation.listDrafts` declares the same member and passes the repository's rows through unchanged. The docblock on the publish batch's closure read, which listed the six header members, now lists seven. The maintainer's direction on objectstack-ai/objectui#11862, quoted by the card: 「所有地方以标签为主,机器名只作为次要信息」. The producer carries the label; consumers do not each fetch one. Triage's grade, quoted: "Each `_drafts` row carries the draft body's own label, or `null`"; "Add a row whose draft body declares no label and reads `null`, never a fallback." ## Measurements behind the shape (each PM hypothesis, measured on this branch's base `238222d8c`) **H1: holds, with one refinement.** `listDrafts` calls `engine.find('sys_metadata', { where, context })` with no field projection, so the whole row is in hand. The body is NOT a column of its own: `sys_metadata` has no `label` column (`packages/metadata-core/src/objects/sys-metadata.object.ts`); the label lives inside the `metadata` textarea column, stored as JSON text (an already-parsed object on a JSON-column dialect). So the producer parses `row.metadata` once per draft row, through the same `storedRowBody` reader `rowToItem` uses. No second query. Label spelling per metadata type, read from `getMetadataTypeSchema(type)` over every `DEFAULT_METADATA_TYPE_REGISTRY` entry (built `packages/spec/dist`, `z.toJSONSchema`, input side): | top-level `label` shape | types | |:---|:---| | plain `string` | object, field, hook, picklist, mapping, flow, job, datasource, translation, email_template, doc, book, permission, position, capability, agent, tool, skill | | `I18nLabel` (string or inline locale map) | view (container and view items), page, dashboard, app, action, report, dataset | | no `label` key | seed, api (rows read `null`) | | no schema | external_catalog (any stored `label` is judged by `I18nLabelSchema`) | No registered type spells its display label `title` or nests it. Off-registry: `connector`, `sharing_rule` and `webhook` spell it `label` (string); `analytics_cube` spells it `title` and its schema refuses `label` with guidance to `title`, so a cube draft reads `null` (see Acceptance notes). No ADR-0087 conversion rewrites a top-level `label` (`packages/spec/src/conversions/registry.ts`: the only `label` entries are the nested `datasource.external.label` removal and fixtures), so the stored spelling is already the canonical one and reading it raw needs no conversion replay. **H2: holds; no third file moves.** Both faces serve the protocol's return whole: `packages/rest/src/rest-server.ts` `GET ${metaPath}/_drafts` ends in `res.json(result)`, and `packages/runtime/src/domains/meta.ts` `_drafts` ends in `deps.success(data)`. `packages/client/src/index.ts` `meta.listDrafts` types its answer as the spec's `ListDraftsResponse`. `packages/rest` and `packages/runtime` are untouched. **H3: holds.** Seven types declare `label` as `I18nLabelSchema` (string or inline locale map such as `{ en, 'zh-CN' }`). What the producer does with each shape: - **plain string**: carried verbatim. - **inline locale map**: carried verbatim, not resolved. The route takes no locale, so resolving here would be the producer choosing a language for the reader; the reader resolves it the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`, or objectui's `pickLocalized`). - **absent / `null`**: `null`. - **any other stored value** (a number, an array, the retired key-reference form): `null`. `I18nLabelSchema.safeParse` is the judge, so the declared field never carries a shape its own type rules out. Draft saves are schema-validated (`resolveOverlaySchema` in `saveMetaItem`), so this is reachable only for rows stored before a type's schema was enforced on save, or for a type with no registered schema. - **stored bytes that do not parse**: `null`, and the draft stays listed. This is `lockHead`'s answer for the same bytes, in the same file, for the same reason: a header listing must not become a parse failure. The draft stays visible and discardable, while every read of its body still fails loudly. ## Landing and surface The landing matches the claim's surface, plus one test file outside it, named here with its reason: - `packages/objectql/src/sys-metadata-repository-list-drafts.test.ts` is a test of `SysMetadataRepository.listDrafts` that lives in `packages/objectql`. Its #6599 disclosure pin asserted exactly six header keys, so it goes red on any seventh. It now names seven keys. Its fixture now carries the body in the `metadata` column the repository actually reads, beside the two older spellings, so the whole-payload sweep covers the real column. It asserts the label arrives while every field-level secret stays off the wire. Its docblock said the routes had "no capability gate"; both routes now gate on `mayReadPendingDrafts`, so that sentence is corrected. - Declared cross-lane files: `packages/metadata-protocol` (`domain:engine`), declared by the seat on #6367. ## Tests Readings at head `317b208be`. Every run went through `scripts/pm/os-verify-lock.sh` on a shared box. The full report comment on the card carries the rest. - `@objectstack/spec` tests: - project `local`, all 8 shards: 625 files, 18713 passed, 1 todo, 0 failed; - project `repo`, shard 1/2: 27 files, 399 passed. The report states the reading for shard 2/2, or NOT MEASURED with the reason; - `typecheck` (tsc, scripts and the test layer) exits 0. - `@objectstack/metadata-protocol` tests: the whole suite has 221 files passed and 3 skipped, with 28287 tests passed and 19 skipped. `typecheck` exits 0. - `@objectstack/objectql`: `sys-metadata-repository-list-drafts.test.ts` has 7 passed. `typecheck` (with the test layer) exits 0. - `@objectstack/rest`: the 18 test files that name `_drafts` have 906 tests, all passed. `packages/rest` itself is unchanged. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` was re-derived after the change and gave 113 families. All 113 were run, every one exited 0, and `--ran` reports "113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED". `pnpm --filter @objectstack/spec check:generated --fix` regenerated only `content/docs/references/api/protocol.mdx`, and `check:docs` re-checked green. What the new pins cover: - `@objectstack/spec`: the schema pin "ListDraftsResponseSchema declares the pending-drafts body" now carries a string label, an inline-locale-map label and a no-label row reading `null`. It also pins that a row omitting `label` is refused at `drafts.0.label`. - `@objectstack/metadata-protocol` (`sys-metadata-repository-14938-list-drafts-updated-at.test.ts`, which shares its pinned engine double): the declaration-keyed `CONFORMS` table gains `label`, so every existing case also checks it. New `#22200` cases cover: - a label written through the real `put(..., { state: 'draft' })` and read back; - a body with no label, which reads `null`, not the name; - a locale map carried verbatim; - a JSON-column body; - three off-spec labels, each reading `null`; - torn stored bytes, which read `null` while the draft stays listed; - `ObjectStackProtocolImplementation.listDrafts` passing the label through, its response parsed by `ListDraftsResponseSchema` with every member preserved, and exactly seven header keys with no body residue. - Ablation, run once and not committed, through `scripts/ablation-replace.mjs`: the producer line `label: draftBodyLabel(row),` was replaced by the forbidden fallback `label: row.name ?? null,`. All 10 `#22200` cases went red and the 8 `#14938` cases stayed green. The file was restored to its HEAD blob and `git diff HEAD` was empty. - Reverse type verification: a `ListDraftsResponse` literal without `label`, planted in a metadata-protocol test file, fails `tsc` with `TS2741: Property 'label' is missing`. So the consumer reads the rebuilt spec `.d.ts`. The file was restored to its HEAD blob. ## Acceptance notes (observations; not filed) - **`analytics_cube` drafts read `label: null`.** `CubeSchema` spells its display name `title` and refuses `label` with guidance to `title`. This PR reads only `label`, the field the ruling names. A per-type display-key mapping would be a second vocabulary for the producer to keep in step, so it is not added here. Reach is not measured: no cube draft producer was found in this repository. Carrier: none. - **An empty-string `label` (`''`) is carried as `''`.** It is the body's own value and passes `I18nLabelSchema`. Whether a reader treats it as absent is the reader's choice. - **objectui's half stays on objectstack-ai/objectui#11862.** `MetadataDraftHeader` in `@object-ui/data-objectstack` follows after the pin bump that carries this field. No objectui change is made here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba8af87 commit c512c25

8 files changed

Lines changed: 311 additions & 17 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null`
7+
8+
Clause-②: yes (widening)
9+
10+
- **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").
11+
- **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`).
12+
- **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.
13+
- **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.
14+
- **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes.

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

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

20882088
| Property | Type | Required | Description |
20892089
| :--- | :--- | :--- | :--- |
2090-
| **drafts** | `{ type: string; name: string; organizationId: string \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. |
2090+
| **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. |
20912091

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

20942094
| Property | Type | Required | Description |
20952095
| :--- | :--- | :--- | :--- |
20962096
| **type** | `string` | ✅ | Metadata type name (canonical singular). |
20972097
| **name** | `string` | ✅ | Item name. |
2098+
| **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. |
20982099
| **organizationId** | `string \| null` | ✅ | Owning organization of the draft row, `null` for an environment-wide draft. |
20992100
| **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. |
21002101
| **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. |

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22833,7 +22833,10 @@ export class ObjectStackProtocolImplementation implements
2283322833
* by `packageId` and/or `type`. The list reads of `getMetaItems` only see
2283422834
* the ACTIVE registry; this exposes what an AI authored but a human hasn't
2283522835
* published yet, so the console can show a "pending changes" surface and a
22836-
* just-built app package isn't displayed as empty. No body is returned.
22836+
* just-built app package isn't displayed as empty. No body is returned —
22837+
* only its own `label` ([#22200]), the one member a draft-only item can be
22838+
* shown by other than its machine name, passed through from
22839+
* {@link SysMetadataRepository.listDrafts} as the repository projects it.
2283722840
*/
2283822841
async listDrafts(request?: {
2283922842
packageId?: string;
@@ -22843,6 +22846,8 @@ export class ObjectStackProtocolImplementation implements
2284322846
drafts: Array<{
2284422847
type: string;
2284522848
name: string;
22849+
/** The draft body's own top-level `label`, as authored; `null` when it declares none. */
22850+
label: I18nLabel | null;
2284622851
organizationId: string | null;
2284722852
packageId: string | null;
2284822853
updatedAt: string | null;
@@ -22909,8 +22914,9 @@ export class ObjectStackProtocolImplementation implements
2290922914
* ## Why the batch's existing enumeration cannot supply the bodies
2291022915
*
2291122916
* `listDrafts` — the read that DEFINES this batch — is a declared header
22912-
* projection: it maps rows to `(type, name, organizationId, packageId,
22913-
* updatedAt, updatedBy)` and drops `metadata` on purpose, because its other
22917+
* projection: it maps rows to `(type, name, label, organizationId,
22918+
* packageId, updatedAt, updatedBy)` and drops `metadata` on purpose — of the
22919+
* body only its own top-level `label` leaves ([#22200]) — because its other
2291422920
* caller is the console's "pending changes" list. Widening it would put
2291522921
* every draft BODY on that listing, and it would not even remove the guard
2291622922
* below: the doubles that lack `repo.get` stub `listDrafts` too, so a

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

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ import {
6666
assertEngineUpdateDispatch,
6767
assertEngineFindOnePredicate,
6868
} from '@objectstack/metadata-core';
69+
import { ListDraftsResponseSchema } from '@objectstack/spec/api';
70+
import { I18nLabelSchema } from '@objectstack/spec/ui';
6971
import { SysMetadataRepository } from './sys-metadata-repository.js';
72+
import { ObjectStackProtocolImplementation } from './protocol.js';
7073

7174
interface Row {
7275
[k: string]: unknown;
@@ -107,6 +110,8 @@ const INVALID_INSTANT = new Date(NaN);
107110
const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } = {
108111
type: (v) => typeof v === 'string',
109112
name: (v) => typeof v === 'string',
113+
// [#22200] The draft body's own label: an `I18nLabel` as authored, or `null`.
114+
label: (v) => v === null || I18nLabelSchema.safeParse(v).success,
110115
organizationId: (v) => v === null || typeof v === 'string',
111116
packageId: (v) => v === null || typeof v === 'string',
112117
updatedAt: (v) => v === null || typeof v === 'string',
@@ -346,3 +351,165 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever
346351
});
347352
});
348353
});
354+
355+
/**
356+
* [#22200] The `_drafts` header carries the draft body's own `label`.
357+
*
358+
* `GET /meta/_drafts` is the only list of draft-only items a client has, so a
359+
* header without a label leaves such an item showable by its machine name
360+
* alone — a permission set just created as a draft read `technician`, not
361+
* "Technician". The label is read off the row `listDrafts` already holds (no
362+
* second query), carried as authored, and `null` when the body declares none:
363+
* ⛔ never the item name standing in for it.
364+
*
365+
* Shares this file's engine double rather than pinning a second one: the
366+
* projection under test is the same `listDrafts` map, and the conformance
367+
* table above now covers `label` on every case in the file.
368+
*/
369+
describe('#22200 — each listDrafts row carries the draft body\'s own label, or null', () => {
370+
const HEADER_KEYS = ['label', 'name', 'organizationId', 'packageId', 'type', 'updatedAt', 'updatedBy'];
371+
372+
describe('§A drafts saved through the real write path', () => {
373+
it('a draft whose body declares a label returns it', async () => {
374+
const engine = makeFakeEngine();
375+
const repo = makeRepo(engine);
376+
await repo.put(
377+
{ org: 'env', type: 'view', name: 'repair_ticket_board' } as never,
378+
{ name: 'repair_ticket_board', label: 'Repair Ticket Board' },
379+
{ parentVersion: null, actor: 'usr_1', state: 'draft' } as never,
380+
);
381+
// Non-vacuity: the writer stored a DRAFT row whose body is serialized
382+
// text, the shape the read below has to parse.
383+
expect(engine.rows).toHaveLength(1);
384+
expect(engine.rows[0]!.state).toBe('draft');
385+
expect(typeof engine.rows[0]!.metadata).toBe('string');
386+
387+
const drafts = await repo.listDrafts();
388+
expect(drafts).toHaveLength(1);
389+
expect(drafts[0]!.label).toBe('Repair Ticket Board');
390+
expectConformsToDeclaration(drafts);
391+
});
392+
393+
it('a draft whose body declares none reads null — not its machine name', async () => {
394+
const engine = makeFakeEngine();
395+
const repo = makeRepo(engine);
396+
await repo.put(
397+
{ org: 'env', type: 'view', name: 'repair_ticket_board' } as never,
398+
{ name: 'repair_ticket_board' },
399+
{ parentVersion: null, actor: 'usr_1', state: 'draft' } as never,
400+
);
401+
402+
const drafts = await repo.listDrafts();
403+
expect(drafts).toHaveLength(1);
404+
expect(drafts[0]!.label).toBeNull();
405+
expect(drafts[0]!.label).not.toBe(drafts[0]!.name);
406+
expectConformsToDeclaration(drafts);
407+
});
408+
});
409+
410+
describe('§B the label is carried AS AUTHORED', () => {
411+
it('an inline locale map (the `I18nLabel` form) is carried verbatim, not resolved to one locale', async () => {
412+
// The route takes no locale, so resolving here would be the producer
413+
// choosing a language for the reader.
414+
const map = { en: 'Field Service', 'zh-CN': '现场服务' };
415+
const engine = makeFakeEngine([
416+
draftRow({ type: 'app', name: 'field_service', metadata: JSON.stringify({ name: 'field_service', label: map }) }),
417+
]);
418+
const repo = makeRepo(engine);
419+
420+
const drafts = await repo.listDrafts();
421+
expect(drafts[0]!.label).toEqual(map);
422+
expectConformsToDeclaration(drafts);
423+
});
424+
425+
it('reads a body the driver already materialised as an object (a JSON-column dialect)', async () => {
426+
const engine = makeFakeEngine([draftRow({ metadata: { label: 'Cases' } })]);
427+
const repo = makeRepo(engine);
428+
429+
expect(typeof engine.rows[0]!.metadata).toBe('object');
430+
431+
const drafts = await repo.listDrafts();
432+
expect(drafts[0]!.label).toBe('Cases');
433+
expectConformsToDeclaration(drafts);
434+
});
435+
});
436+
437+
describe('§C null is the declared "no label", never an invented one', () => {
438+
it.each([
439+
['a number', 42],
440+
['the retired key-reference form', { key: 'views.case_grid.label', defaultValue: 'Cases' }],
441+
['an array', ['Cases']],
442+
])('a stored label the contract cannot carry (%s) reads null, and the draft is still listed', async (_shape, label) => {
443+
const engine = makeFakeEngine([draftRow({ metadata: JSON.stringify({ label }) })]);
444+
const repo = makeRepo(engine);
445+
446+
// Non-vacuity: the stored body really carries a `label` key.
447+
expect(JSON.parse(engine.rows[0]!.metadata as string)).toHaveProperty('label');
448+
449+
const drafts = await repo.listDrafts();
450+
expect(drafts).toHaveLength(1);
451+
expect(drafts[0]!.label).toBeNull();
452+
expectConformsToDeclaration(drafts);
453+
});
454+
455+
it('stored bytes that do not parse read null rather than failing the whole listing', async () => {
456+
const engine = makeFakeEngine([
457+
draftRow({ name: 'torn_grid', metadata: '{"label":"Torn' }),
458+
draftRow({ name: 'lead_grid', metadata: '{"label":"Leads"}' }),
459+
]);
460+
const repo = makeRepo(engine);
461+
462+
expect(() => JSON.parse(engine.rows[0]!.metadata as string)).toThrow();
463+
464+
const drafts = await repo.listDrafts();
465+
expect(drafts.map((d) => [d.name, d.label]).sort()).toEqual([
466+
['lead_grid', 'Leads'],
467+
['torn_grid', null],
468+
]);
469+
expectConformsToDeclaration(drafts);
470+
});
471+
});
472+
473+
describe('§D the protocol passes it through, and the spec schema agrees', () => {
474+
it('ObjectStackProtocolImplementation.listDrafts serves the label, and the response parses as ListDraftsResponseSchema, every member preserved', async () => {
475+
const engine = makeFakeEngine([
476+
draftRow({ type: 'permission', name: 'technician', metadata: JSON.stringify({ name: 'technician', label: 'Technician' }) }),
477+
draftRow({ type: 'object', name: 'repairs_repair_ticket', metadata: JSON.stringify({ name: 'repairs_repair_ticket' }) }),
478+
]);
479+
const protocol = new ObjectStackProtocolImplementation(engine as never);
480+
481+
const response = await protocol.listDrafts();
482+
const byName = Object.fromEntries(response.drafts.map((d) => [d.name, d]));
483+
expect(byName.technician!.label).toBe('Technician');
484+
expect(byName.repairs_repair_ticket!.label).toBeNull();
485+
486+
const parsed = ListDraftsResponseSchema.safeParse(response);
487+
expect(parsed.success).toBe(true);
488+
if (parsed.success) expect(parsed.data).toEqual(response);
489+
});
490+
491+
it('the label is the ONLY member read off the body — the header keys are exactly seven (#6599)', async () => {
492+
const engine = makeFakeEngine([
493+
draftRow({
494+
type: 'object',
495+
name: 'account',
496+
metadata: JSON.stringify({
497+
name: 'account',
498+
label: 'Account',
499+
description: 'internal pricing notes',
500+
fields: { salary_grade: { type: 'select', label: 'Salary Grade' } },
501+
}),
502+
}),
503+
]);
504+
const protocol = new ObjectStackProtocolImplementation(engine as never);
505+
506+
const response = await protocol.listDrafts();
507+
expect(Object.keys(response.drafts[0]!).sort()).toEqual(HEADER_KEYS);
508+
const wire = JSON.stringify(response);
509+
for (const secret of ['internal pricing notes', 'salary_grade', 'Salary Grade', 'fields']) {
510+
expect(wire).not.toContain(secret);
511+
}
512+
expect(response.drafts[0]!.label).toBe('Account');
513+
});
514+
});
515+
});

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import type {
8181
} from '@objectstack/metadata-core';
8282
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
8383
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
84+
import { I18nLabelSchema, type I18nLabel } from '@objectstack/spec/ui';
8485
import type { IObjectQLEngine } from '@objectstack/core';
8586
// [#7682] The read-only-package predicate, imported rather than re-spelled —
8687
// the same function `saveMetaItem`'s ADR-0070 D1 gate and the `/packages`
@@ -195,6 +196,41 @@ function storedRowBody(row: any): Record<string, unknown> {
195196
return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {});
196197
}
197198

199+
/**
200+
* [#22200] A draft row's own top-level `label`, for the `_drafts` header
201+
* ({@link SysMetadataRepository.listDrafts}) — the ONE member that header
202+
* reads off the stored body. The header stays a disclosure boundary (#6599):
203+
* the label leaves, the body never does.
204+
*
205+
* Carried AS AUTHORED. Every registered metadata type that declares a display
206+
* label spells it top-level `label`, as a plain string or an `I18nLabel` inline
207+
* locale map; the route takes no locale to resolve a map with, so the reader
208+
* resolves it. No ADR-0087 conversion rewrites a top-level `label`, so the
209+
* stored spelling is already the canonical one.
210+
*
211+
* `null` — the declared "no label" — in three cases, and ⛔ never the item
212+
* name, which would make "has a label" and "has none" one answer:
213+
* - the body declares none;
214+
* - it declares one `I18nLabelSchema` refuses (a row stored before its
215+
* type's schema was enforced on save, or a row of a type with no
216+
* registered schema). The declared field cannot carry it, and serving it
217+
* would hand the reader a shape its own type rules out;
218+
* - its stored bytes do not parse. {@link SysMetadataRepository.lockHead}
219+
* answers the same bytes the same way, for the same reason: a header
220+
* listing must not turn into a parse failure. The draft stays listed — and
221+
* so discardable — while every read of its body still fails loudly.
222+
*/
223+
function draftBodyLabel(row: any): I18nLabel | null {
224+
let body: Record<string, unknown> | null;
225+
try {
226+
body = storedRowBody(row);
227+
} catch {
228+
return null;
229+
}
230+
const parsed = I18nLabelSchema.safeParse(body?.label);
231+
return parsed.success ? parsed.data : null;
232+
}
233+
198234
/**
199235
* Overlay-row lifecycle state.
200236
*
@@ -1331,6 +1367,12 @@ export class SysMetadataRepository implements MetadataRepository {
13311367
Array<{
13321368
type: string;
13331369
name: string;
1370+
/**
1371+
* [#22200] The draft body's own top-level `label`, as authored — `null`
1372+
* when it declares none (see {@link draftBodyLabel}). For a draft-only
1373+
* item this header is the only place a client can find a label at all.
1374+
*/
1375+
label: I18nLabel | null;
13341376
/**
13351377
* The scope the draft actually lives in — `null` for an env-wide draft,
13361378
* a string for a per-org overlay draft. The `$or` below surfaces BOTH to
@@ -1350,6 +1392,8 @@ export class SysMetadataRepository implements MetadataRepository {
13501392
return (rows as any[]).map((row) => ({
13511393
type: row.type,
13521394
name: row.name,
1395+
// [#22200] Off the row this read already holds — no second query.
1396+
label: draftBodyLabel(row),
13531397
organizationId: row.organization_id ?? null,
13541398
packageId: row.package_id ?? null,
13551399
// [commit c383352cb] `updated_at` / `created_at` are the BUILTIN audit columns,

0 commit comments

Comments
 (0)