Skip to content

Commit ec29753

Browse files
fix(metadata-protocol): the metadata list serves every stored seed item, named by its row (#31480)
domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy Body: seat refresh (round 1) · 2026-11-08T11:58Z <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1nhftM2hZHHvqk2GLFyVTN)_ domain:server#3 · os-project-manager · session_01FEkQ5nBSzc6ktJyjdFcDb3 Fixes #31399 Clause-②: no (a list read starts serving rows already stored and already served by name; no accepted input changes, no SeedSchema change) Metadata-protocol: none ## 维护者速读 - 改了什么:元数据列表合并 `mergePackageAwareOverlay` 遇到「正文里没有 `name`」的存储项(种子 `seed`,`SeedSchema` 不声明 `name`,名字在 `sys_metadata` 行上)时,改用该行的 `name` 列归槽,并在列出的副本上带出这个名字;列表的诊断(`_diagnostics`)按行里存的正文判定,不把这个补上的名字算作违规。存储的正文不改,注册表水合不改,`SeedSchema` 不改。 - 为什么改:`GET /api/v1/meta/seed` 与 `getMetaItems({ type: 'seed' })` 一条存储的种子都列不出来,而 `GET /api/v1/meta/seed/:name` 每条都读得到。真实 showcase 启动实测:3 条已发布种子,列表 `[]`。 - 风险与回滚:只影响正文里根本没有 `name` 键的存储项:`seed`,也包括其它无名的存储行(例如 schema 尚未声明 `name` 时存下的旧 translation 行、畸形的无名行),它们以前从列表里消失,现在按行名列出(诊断照旧按存储正文判定)。`object_extension` 按 `extend` 归槽,不受影响。正文自带 `name` 的(包括 `name: null`)照旧按自身名字列出,带 `name` 的旧种子与 `name: null` 的正文仍被诊断标出(有对照 pin)。列出的副本多带一个 `name`,原样 PUT 回严格 schema 会被拒(按名读不受影响)。metadata-protocol、objectql、cloud-connection、rest 全量测试全绿。回滚即还原本 PR。 - 先看:`packages/metadata-protocol/src/protocol-overlay-merge.ts` 的 `mergePackageAwareOverlay` 记录循环与 `SERVED_ROW_NAME`,`protocol-meta-list-reads.ts` 的 `decorateListedItem`,新 pin `protocol.seed-list-row-name.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.seed-list-row-name.test.ts`,预期 11 条全绿;其中 7 条在 main 上是红的。 - 需要你做的:无,席位落地。 ## What changes - **`mergePackageAwareOverlay`** (`protocol-overlay-merge.ts`): a stored record whose body states no slot name of its own (`listSlotNameOf` answers nothing) is slotted by its row's `name` column, the name the by-name read finds it under, and served as a copy carrying that `name`. The copy is marked with a symbol key, `SERVED_ROW_NAME`, which object spreads along the list keep and the wire never sees. A body that states its own name keeps it, `name: null` included (only an absent `name` falls to the row's). The stored body is never written. ⚠️ The one exception to `listSlotNameOf`'s "what a read serves PUTs back": a listed row-named item carries a `name` its strict schema refuses, so it is refused if PUT back verbatim; the by-name read serves the body as stored. Stated once in the `SERVED_ROW_NAME` docblock, pointed to from `listSlotNameOf` and `getMetaItemsForExecution`. `StoredRowPlace` gains the optional `name`. - **`readFlattenedMetaItems`** (`protocol-meta-list-reads.ts`): the active, stand-in, override stand-in and draft-preview records pass their row's `name` in `stored`. The registry hydration loop is untouched, so seeds are still not registered as metadata items (`packages/metadata/src/plugin.ts` keeps its note). The served exit decorates through `decorateListedItem`: a row-named item's `_diagnostics` are computed on the body as stored (without the served `name`, which `SeedSchema` does not declare), then the item is served with the name. Every other item is decorated exactly as before. - The MetadataService merge and `protocol-stored-collection-fold.ts` pass no `stored`, so they are unchanged. ## Tests - New `packages/metadata-protocol/src/protocol.seed-list-row-name.test.ts` (11 rows): - §1: N=3 stored seed rows list 3 named items on the environment's list and on the package-scoped list, each body and `_diagnostics` equal to what `getMetaItem` serves under that name, and `names` narrows by the row's name. - §2: a package's shipped seed and its stored row of the same name are one slot (the stored row's), and another package's same-name seed keeps its own slot. - §3: the draft preview lists draft seed rows by their row names. - §4 controls: a body that names itself keeps its name (a dashboard), a stored seed body that spells the retired `name` keeps its diagnostic as the by-name read does, a body whose `name` is null is still listed once with `_diagnostics.valid` false (as on main), and listing does not write the stored body. ## Acceptance notes - **The door measured first (order step 1).** On a real showcase boot (dogfood `bootShowcase`, a scratch probe, not committed), with 3 seed rows published into an environment package, `GET /api/v1/meta/seed`, `?package=` and `/meta/seeds` all answered `{"type":"seed","items":[]}`, while `GET /api/v1/meta/seed/s_one` served the body. The door and the protocol method agree, so the card was not narrowed. - **Responsibility (by blame).** Keying the list on the body's own `name` began in ee9312f (2026-04-23, "feat(template-seeding)", `if (data && typeof data === 'object' && 'name' in data) byName.set(data.name, data)`), carried through ac1fc4c (#1569). It was restated as the merge's skip `if (!(body && … 'name' in body)) continue;` in 0e41302 (#3231, ADR-0048), and moved to `listSlotNameOf` + `if (bodyName === undefined) continue;` in 0c1829a (#28811 / #28574). Seeds became stored items with no `name` in the body and met that skip. - **Found on the real boot, fixed here.** The first cut served the row name but the list's diagnostics then judged it against the closed `SeedSchema` and marked every listed seed `valid: false` (`Unrecognized key(s) on this seed: name`). The served exit now judges such a body as stored. After the fix the same probe lists `s_one`, `s_three`, `s_two`, each with `_packageId`, `name` and `_diagnostics: {"valid":true}`, on all three list forms. - **#30808's install-local seed receipt** (`readUnmanagedCopySeedReceipt`, `unmanagedCopySeedNames`): it could go back to a list read now. `getMetaItems({ type: 'seed', packageId })` serves each of the base's seed rows under its name, with the same served-row order as the by-name read, and `seedDatasetOfReceiptItem` projects to `SeedSchema`'s members, so the served `name` would not reach the loader. Not changed here, per the card. - **Worktree.** The claim names `objectstack-issue-31399`; this ran in the chain's existing worktree `objectstack-issue-31317` on the same claimed branch, cut from fresh `origin/main` after #31398 merged. ## Verification Head `2eeb629e` (base `1ff99441`, the #31398 merge; `origin/main` `f67f3b35` merged once before opening). <details><summary>Red first, fix, suites</summary> - The pin with both source files restored to base: Tests 7 failed | 3 passed (10), e.g. `AssertionError: expected [] to have a length of 3 but got +0`; the three controls green. With the fix: 10 passed. - `@objectstack/metadata-protocol` vitest after the merge of main: Test Files 327 passed | 4 skipped (331), Tests 22587 passed, 26 skipped. - `@objectstack/objectql` vitest after the merge (metadata-protocol from the rebuilt dist): Test Files 571 passed (571), Tests 11558 passed. - Before the merge of main: `@objectstack/cloud-connection` Test Files 75 passed (75), Tests 994 passed; `@objectstack/rest` Test Files 399 passed (399), Tests 7834 passed, 354 skipped; runtime's seed-listing files (`http-dispatcher.test.ts`, `packages-protocol-handle-typing.test.ts`, `packages-publish-drafts-response-conformance.test.ts`) Tests 270 passed. - Contract review (6720799308) should-fix S1: the new `name: null` control row red on `f7fcd126` (Tests 1 failed | 10 passed (11), `expected [] to have a length of 1 but got +0`), green on main's sources and with the fix (11 passed). At the fixed head: `@objectstack/metadata-protocol` vitest Test Files 327 passed | 4 skipped (331), Tests 22588 passed, 26 skipped. - `typecheck` for `@objectstack/metadata-protocol`: exit 0. `eslint --no-inline-config packages/metadata-protocol`: exit 0. </details> <details><summary>Ablation (ablation-replace, each restored to HEAD)</summary> - `protocol-overlay-merge.ts`: `const bodyName = ownName ?? rowName;` made to drop the row name (`String("ABL31399").length < 0 ? rowName : undefined`). Mutation landed (anchor 1 to 0, blob 092cd58 to caaa58088f89). Pin: Tests 7 failed | 3 passed (10). Restored: blob == HEAD, `git diff HEAD` empty. - `protocol-meta-list-reads.ts`: `decorateListedItem` made to judge every item as served (`|| String("ABL31399").length > 0`). Mutation landed (blob 847f260 to b47ee1324e3d). Pin: Tests 1 failed | 9 passed (10), the §1 diagnostics row, `expected { valid: false, errors: [ … ] } to deeply equal { valid: true }`. Restored: blob == HEAD, `git diff HEAD` empty. - Review fix (S1): `const bodyName = ownName !== undefined ? ownName : rowName;`, ablated back to `ownName ?? rowName` (blob 2cb92a5 to cd890289c11b): Tests 1 failed | 10 passed (11), the `name: null` row, `expected [] to have a length of 1 but got +0`. Restored: blob == HEAD, `git diff HEAD` empty. - The pin reads its own `src/`, so no dist leg. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `23eb8808` (63 commands) and again at `f7fcd126` (71, the ledger commit derives 8 more `scripts/` gates), each run with `timeout 1500` and stdin from `/dev/null`, exit codes captured before any pipe, after a `@objectstack/runtime` closure build. First pass: 62 exit 0; `check:engine-double-contract` exit 1, asking the pinned ledger to record the new pin's `findOne` double. Fixed by `node scripts/check-engine-double-contract.mjs --write` (`scripts/engine-double-contract.pinned.json`, +5 lines, the one file outside the claim's surface), then exit 0. The 8 added gates: exit 0. `dispatch-gates --ran`: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. After the review fix, at `2eeb629e`: 79 derived (the touched `protocol.ts` derives 8 more), all 79 run with exit 0 after a metadata-protocol build; `dispatch-gates --ran`: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. Plus typecheck and scoped eslint above (re-run at `2eeb629e`: exit 0). Not run locally (CI): `test:repo`, dogfood shards, type-check lanes. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nBSzc6ktJyjdFcDb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b70d9aa commit ec29753

7 files changed

Lines changed: 331 additions & 12 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the metadata list serves every stored `seed` item, named by its row
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`getMetaItems({ type: 'seed' })`, the read behind `GET /api/v1/meta/seed`, listed no stored seed item while `GET /api/v1/meta/seed/:name` served each one. A seed body states no name (`SeedSchema` declares none; the name lives on the stored row), and the list merge keyed every stored body by its own `name`, so it skipped each seed. The merge now slots a stored body that states no name by its row's name, the name the by-name read finds it under, and serves it carrying that name. This holds on the environment's list, the package-scoped list and the draft preview, and a package's shipped seed of the same name shares that one slot. A body that states its own name keeps it, the stored body is not written, and `SeedSchema` is unchanged.

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ import {
2727
resolveItemLock,
2828
resolveOverlayLockLayer,
2929
} from './item-lock.js';
30-
import { decorateMetadataItems } from './metadata-diagnostics.js';
30+
import { decorateMetadataItem } from './metadata-diagnostics.js';
3131
import {
3232
isActionWithheldByDisabledPackage,
3333
isItemOfDisabledPackage,
3434
} from './protocol-authoring-refusals.js';
3535
import { soleShippingPackage } from './protocol-environment-rows.js';
3636
import type { ProtocolHost } from './protocol-host.js';
3737
import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
38-
import { mergePackageAwareOverlay, servedViewExpansion } from './protocol-overlay-merge.js';
38+
import { mergePackageAwareOverlay, servedViewExpansion, withoutServedRowName } from './protocol-overlay-merge.js';
3939
import {
4040
type ShippingPackages,
4141
boundPackageOf,
@@ -77,6 +77,20 @@ export async function getMetaItemsPerPackage(host: Pick<ProtocolHost, 'readFlatt
7777
return host.readFlattenedMetaItems(canonicalizeMetaRequestType(request), 'served', 'per-package');
7878
}
7979

80+
/**
81+
* [#31399] One listed item, decorated for the served face. An item the merge
82+
* served its row's `name` (a body that states none, a `seed`) is judged as the
83+
* row stores it, without that `name` its schema does not declare, which is the
84+
* verdict the by-name read gives the same row; it is served with the name.
85+
* Every other item is decorated as before ({@link decorateMetadataItem}).
86+
*/
87+
function decorateListedItem(type: string, item: unknown): unknown {
88+
const stored = withoutServedRowName(item);
89+
if (stored === undefined) return decorateMetadataItem(type, item);
90+
const { _diagnostics, ...served } = decorateMetadataItem(type, stored);
91+
return { ...served, name: (item as { name: string }).name, ...(_diagnostics !== undefined ? { _diagnostics } : {}) };
92+
}
93+
8094
/** The body of `ObjectStackProtocolImplementation#readFlattenedMetaItems`; the method's docblock there is its contract. */
8195
export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactLockLayerAt' | 'baseServesBundleMember' | 'convertStoredItem' | 'declinesStoredRow' | 'disabledActionsOf' | 'engine' | 'environmentId' | 'expandStoredViewContainers' | 'findEnvironmentRows' | 'flowRegistrationsOf' | 'foldObjectExtendersFromRegistry' | 'getServicesRegistry' | 'governServedObject' | 'hydrateOverlayIntoRegistry' | 'isStoredEntryOfDeclinedName' | 'isUnservedSealedOverlay' | 'isWritablePackage' | 'lookupArtifactItem' | 'namesWithOwnStoredRow' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'shippedArtifactsOf' | 'shippingPackagesOf' | 'shipsBundleName' | 'storedOverlayEntries'>, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean },
8296
audience: 'served' | 'execution',
@@ -235,23 +249,25 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
235249
// spelling), so the merge picks a package's slot by the
236250
// served-row resolution the by-name read takes
237251
// ({@link servedOverlayRowCandidates}), not by row order.
238-
const placed = mergeable.map(({ data, packageId: recPkg, type: recType }) => ({
239-
data, packageId: recPkg, stored: { type: recType },
252+
// [#31399] …and its row's `name`, which slots a body that states
253+
// none ({@link mergePackageAwareOverlay}).
254+
const placed = mergeable.map(({ name, data, packageId: recPkg, type: recType }) => ({
255+
data, packageId: recPkg, stored: { type: recType, name },
240256
}));
241257
// [#21817] The package-less rows, placed the same way, as
242258
// stand-ins: the same parse and the same stored-row rule.
243259
const standIns = host.storedOverlayEntries(request, standInRows)
244260
.filter((entry) => !host.declinesStoredRow(request.type, (entry.data as { name?: unknown } | null)?.name, storedEntryRow(entry)))
245-
.map(({ data, type: recType }) => ({
246-
data, packageId: undefined, stored: { type: recType }, standIn: true,
261+
.map(({ name, data, type: recType }) => ({
262+
data, packageId: undefined, stored: { type: recType, name }, standIn: true,
247263
}));
248264
// [#28409] The pair's rows, placed the same way and bound to
249265
// the pair, as stand-ins: the merge serves one only where it
250266
// declares the override of the slot's package.
251267
const overrideStandIns = host.storedOverlayEntries(request, overrideRows)
252268
.filter((entry) => !host.declinesStoredRow(request.type, (entry.data as { name?: unknown } | null)?.name, storedEntryRow(entry)))
253-
.map(({ data, packageId: recPkg, type: recType }) => ({
254-
data, packageId: recPkg, stored: { type: recType }, standIn: true,
269+
.map(({ name, data, packageId: recPkg, type: recType }) => ({
270+
data, packageId: recPkg, stored: { type: recType, name }, standIn: true,
255271
}));
256272
items = mergePackageAwareOverlay(request.type, items, [...placed, ...standIns, ...overrideStandIns], (data, prev) => {
257273
if (isView && data && typeof data === 'object') {
@@ -554,7 +570,11 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
554570
// by-name read's preview arm serves, in any row order.
555571
// [ADR-0131 D6/D7] Every row read here is the
556572
// environment's (filtered at the read).
557-
const stored = { type: String(record.type ?? request.type) };
573+
// [#31399] …with its row's `name` (see the active arm).
574+
const stored = {
575+
type: String(record.type ?? request.type),
576+
...(typeof record.name === 'string' ? { name: record.name } : {}),
577+
};
558578
return { data, packageId: recPkg, stored };
559579
};
560580
const drafts = [
@@ -923,7 +943,9 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
923943
type: request.type,
924944
// [#20552] Served: diagnostics + per-type credential redaction.
925945
// Execution: the stored bodies — see `getMetaItemsForExecution`.
926-
items: audience === 'served' ? decorateMetadataItems(request.type, governed) : governed,
946+
// [#31399] An item served its row's `name` (a body that states none)
947+
// is judged as stored, without it, then served with it.
948+
items: audience === 'served' ? governed.map((it) => decorateListedItem(request.type, it)) : governed,
927949
// [#23316] Served only: the engine's verdict on each listed flow —
928950
// see {@link flowRegistrationsOf}. The execution face is the
929951
// engine's own read, so it carries none.

‎packages/metadata-protocol/src/protocol-object-extension-item.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export const OBJECT_EXTENSION_ITEM_SCHEMA = z.preprocess(withoutProtectionEnvelo
8282
* stored under (one extension per package per target). The body is not
8383
* given a `name`: a read serves it as it is stored, so what a GET answers
8484
* PUTs back through the strict schema. `undefined` = not addressable.
85+
* [#31399] The list read's one exception, a body that states no name served
86+
* with its row's, is stated at `SERVED_ROW_NAME` (`protocol-overlay-merge.ts`).
8587
*/
8688
export function listSlotNameOf(canonicalType: string, item: unknown): unknown {
8789
if (!isRecord(item)) return undefined;

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,39 @@ interface ServedOverlayRowCandidate {
4848
*/
4949
interface StoredRowPlace {
5050
readonly type: string;
51+
/**
52+
* [#31399] The row's own `name` column: the name the by-name read finds
53+
* the row under. The merge slots a stored body by it when the body states
54+
* no name of its own ({@link mergePackageAwareOverlay}).
55+
*/
56+
readonly name?: string;
57+
}
58+
59+
/**
60+
* [#31399] Marks a listed body that carries its row's `name` because it states
61+
* none of its own ({@link mergePackageAwareOverlay}). A symbol key: object
62+
* spreads along the list keep it, and it never reaches the wire. The list's
63+
* served exit judges such a body's `_diagnostics` without that `name`
64+
* ({@link withoutServedRowName}), since the document's schema does not
65+
* declare it: the verdict is the one the by-name read gives the same row.
66+
*
67+
* ⚠️ The one exception to {@link listSlotNameOf}'s "the body is not given a
68+
* `name`, so what a GET answers PUTs back through the strict schema": a body
69+
* the list served its row's name (a `seed`; or any stored body that states no
70+
* name, such as a legacy row saved before its schema declared one) carries a
71+
* `name` the strict schema refuses, so a listed item PUT back verbatim is
72+
* refused. The by-name read serves the body as stored, without it.
73+
*/
74+
export const SERVED_ROW_NAME: unique symbol = Symbol('metadata-protocol.servedRowName');
75+
76+
/**
77+
* [#31399] The body a listed item was stored as, when the list served it its
78+
* row's `name` ({@link SERVED_ROW_NAME}); otherwise `undefined`.
79+
*/
80+
export function withoutServedRowName(item: unknown): Record<string, unknown> | undefined {
81+
if (item === null || typeof item !== 'object' || (item as Record<PropertyKey, unknown>)[SERVED_ROW_NAME] !== true) return undefined;
82+
const { name: _name, [SERVED_ROW_NAME]: _mark, ...stored } = item as Record<PropertyKey, unknown>;
83+
return stored as Record<string, unknown>;
5184
}
5285

5386
/**
@@ -357,8 +390,23 @@ export function mergePackageAwareOverlay(
357390
}
358391
}
359392
for (const { data, packageId, stored, standIn } of records) {
360-
const body = data as any;
361-
const bodyName = listSlotNameOf(canonicalType, body);
393+
// [#31399] A stored body that states no name of its own (a `seed`:
394+
// `SeedSchema` declares none, the name lives on the row) is slotted by
395+
// its row's `name` column, the name the by-name read finds it under,
396+
// and served carrying that name, as every listed item carries its own.
397+
// Before, the merge skipped it, so `GET /meta/seed` listed no stored
398+
// seed while `GET /meta/seed/:name` served each one. A body that states
399+
// a name keeps it; the stored body is never written.
400+
const ownName = listSlotNameOf(canonicalType, data);
401+
const rowName = ownName === undefined && stored?.name !== undefined && stored.name !== ''
402+
&& data !== null && typeof data === 'object' && !Array.isArray(data)
403+
? stored.name
404+
: undefined;
405+
const body = (rowName !== undefined ? { ...(data as object), name: rowName, [SERVED_ROW_NAME]: true } : data) as any;
406+
// A body whose own `name` is null still states one: it is listed under
407+
// it (with `_diagnostics` flagging it), as before; only an absent name
408+
// falls to the row's.
409+
const bodyName = ownName !== undefined ? ownName : rowName;
362410
if (bodyName === undefined) continue;
363411
// The base row this record shadows at its own slot (for view-identity
364412
// healing): a same-package row, else a package-less one, else any

0 commit comments

Comments
 (0)