Skip to content

Commit f285338

Browse files
fix(metadata-protocol): name a stored object_extension conversion notice by its target, one notice per row (#29716)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ82Kk5X4KDzo3EwRJCHqDmGG)_ Fixes #29596 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:协议层存量行转换告警改用条目自身的身份命名,`object_extension` 用其 `extend` 目标,每行各报一条。 - 为什么改:扩展体没有 `name`,此前所有过期扩展行都报成 `object_extension/` 加 unnamed 占位,且共用一个去重键,三行只报一条。 - 风险与回滚:只动告警文字与去重键,行的转换结果不变;其它类型照旧按 `name` 命名。回滚即撤销本 PR。 - 先看:`packages/metadata-protocol/src/protocol-stored-item-conversion.ts` 的一行 `listSlotNameOf`;其次新测试文件。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol-stored-item-conversion.notice-name.test.ts`,应见 4 个测试通过。 - 需要你做的:无,席位落地。 ## What changes - `packages/metadata-protocol/src/protocol-stored-item-conversion.ts` `convertStoredItem`: the notice's name, and the warn-once key built from it, read `listSlotNameOf(singular, data)` instead of `data.name`. That helper is the one list reads already slot an item by: an item's own `name`, and for an `object_extension` body (which carries `extend` and no `name`) its target. The module header gains one sentence saying the notice's name read has changed since the #25467 move. No new spelling; the body is not given a `name`. - `packages/metadata/src/loaders/database-loader.ts`: **no change.** Measured below, its notice reads the stored row's `name` column, which for an extension row is already its target. - Tests: `protocol-stored-item-conversion.notice-name.test.ts` (new) and one `describe` in `database-loader.test.ts`. - `.changeset/29596-extension-notice-name.md`: `patch` on `@objectstack/metadata-protocol` (the function ships in its `dist`). ## Why Measured first, on a real store: the runtime rig of `meta-object-extension-item.test.ts` (better-sqlite3 + real `ObjectQL` + real `ObjectStackProtocolImplementation`). Two extensions (`crm_account`, `crm_contact`) were saved and published through the save door into `com.acme.crm.custom`, then each stored row's `time` default was made stale (`12:00Z`). The rows were then read through `getMetaItems`, `getMetaItem` (twice) and `loadMetaFromDb`, and through a `DatabaseLoader` over the same engine. The probe was a scratch test and is not committed. - **Protocol seam, `data.name`:** absent on all six `convertStoredItem` calls (`'name' in data` false; `extend` = `crm_account` / `crm_contact`). Output: ONE line, `[Protocol] stored object_extension/` followed by the unnamed placeholder, for both rows. - **Database-loader seam, `row.name`:** `crm_account` and `crm_contact`, the target names the save door stores the rows under. Output: two lines, `[DatabaseLoader] stored object_extension/crm_account …` and `… /crm_contact …`. So the card's premise holds for the protocol seam and not for the loader seam. The loader already names each row by the identity every type is stored under, so it only gets a pin. ## Tests - `protocol-stored-item-conversion.notice-name.test.ts`, run through the module's own `convertStoredItem` over the real chain (`convertStoredItemDetailed`): - two stale extension rows give two notices, `object_extension/crm_account` and `object_extension/crm_contact`; the row still converts, and the body gains no `name`; - re-reading one row does not warn again (the dedupe still holds per row); - CONTROL: `object` rows are still named by `name` (`object/shift`, `object/rota`), and a nameless body still gets the unnamed placeholder; - CONTROL: an `extend` key on a non-extension type does not name it. - `database-loader.test.ts` › `stored conversion notice names the row (#29596)`: two stale extension rows loaded through `loadMany` give two notices naming their targets, and the defaults come back converted. CONTROL: `object` rows are named by row name. <details><summary>Ablation (committed first, restored by blob hash)</summary> `node scripts/ablation-replace.mjs` put the old read back (`const name = (data as { name?: unknown } | null | undefined)?.name;`). Anchor 1 → 0, blob `17ae402a` → `c4063b6d`. Result: 2 failed / 2 passed. Both extension cases fail with received `["object_extension/" + unnamed placeholder]` (one line for two rows); both CONTROLs stay green. Restored blob `17ae402a` == HEAD, and `git diff HEAD` is empty. The test imports the module from `src` by relative path, so no `dist` rebuild was involved. </details> ## Acceptance notes - No overlap found before editing: of 37 open PRs, none touches either file. #29683 touches the sibling `protocol-meta-layered-reads.ts` only. No live `Claim:` covers `convertStoredItem` or the loader's notice block; the cut-23 claim on #25467 is for the move that already landed. - No `protocol.ts` or `packages/spec` edit. ## Verification At head `90c78f06` (branch off `origin/main` `1c1a5d40`): - `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata typecheck`: exit 0. `tsc --listFiles` includes both test files. - `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata test`: exit 0. metadata 68 files / 958 tests; metadata-protocol 309 passed + 4 skipped files, 22266 passed + 26 skipped tests. - `node scripts/pm/dispatch-gates.mjs --commands`: 57 derived. `--ran`: 57 accounted, 56 run, 1 NOT MEASURED, 0 unrun. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package built). CI's `Lint & Repo Gates` runs it. - `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone. After fetching its pinned fixture commit, it exits 0. - The derived run started at `de3bb7a9`. The final commit only types the two tests' `console.warn` spies. The 18 test-reading gates were rerun at `90c78f06`, all exit 0: engine-double-contract, objectql-double-limit, test-source-alias, cross-package-test-inputs, rest-log-spy-declared, refd-timer-probe, where-matcher, logger-receiver-detach, nul-bytes, issue-citations (both), comment-mask-adoption, durability-log-level, registry-log-declared, system-context-census, tier-file-adoption, type-check-coverage, keyed-text-bounds. - `pnpm lint`: exit 0 at `90c78f06`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. (No new exported name.) - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the dispatch contract's Chinese summary). Signed domain:server#1 · os-warren · session_01JQwxbZWEwRacLdvKTeMCb2 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZWEwRacLdvKTeMCb2 --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZWEwRacLdvKTeMCb2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c76c0e0 commit f285338

4 files changed

Lines changed: 149 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a stored `object_extension` conversion notice names the extension's target, one notice per row
6+
7+
Clause-②: no
8+
9+
Metadata-protocol: none
10+
11+
When a stored row carries a pre-protocol shape, the protocol's read seams print `[Protocol] stored TYPE/NAME carries a pre-protocol shape; …` once per conversion and row. The name was read from the body's `name` key. An `object_extension` body carries `extend` and no `name`, so every stale stored extension was reported as `object_extension/<unnamed>`, and all of them shared one notice: with three stale extensions an operator heard about one, unnamed.
12+
13+
- **Now.** The notice names the item by the identity its type uses, the same one a list read slots it under. An extension is named by its target (`object_extension/crm_account`), and each stale extension row gets its own notice.
14+
- **Unchanged.** Rows convert exactly as before, and the body is not given a `name`. Every other type is still named by its `name`, and a body with none still reads `<unnamed>`. The `[DatabaseLoader]` notice already named each row by its stored row name, which for an extension is its target.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29596] The stored-row conversion notice names the item by the identity
5+
* its type uses, and the warn-once key separates rows by it.
6+
*
7+
* An `object_extension` body carries `extend` and no `name`
8+
* (`ObjectExtensionSchema` refuses one; its row is stored under the target's
9+
* name). Since #29589 a stored extension row replays the `objectExtensions[]`
10+
* conversions, so `convertStoredItem` warns for it. Reading `data.name`, it
11+
* warned every stale extension as `object_extension/<unnamed>`, and the key
12+
* `conversionId|object_extension|` collapsed every row to ONE notice per
13+
* conversion. Measured on a real store (SQLite + ObjectQL + the protocol's
14+
* save door, two published extensions made stale): six reads, `data.name`
15+
* absent on every one, and a single `<unnamed>` line for the two rows.
16+
*
17+
* Driven through the module's own `convertStoredItem` over the real
18+
* conversion chain (`convertStoredItemDetailed`), with the warn-once set the
19+
* class hands it.
20+
*/
21+
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23+
import { convertStoredItem, convertStoredItemDetailed } from './protocol-stored-item-conversion.js';
24+
25+
/** A host shaped like the one the class builds: the real chain, a fresh warn-once set. */
26+
const host = () => ({ convertStoredItemDetailed, storedConversionWarned: new Set<string>() });
27+
28+
/** A stale extension: the `Z`-suffixed `time` default `time-default-utc-suffix-dropped` converts. */
29+
const staleExtension = (target: string) => ({
30+
extend: target,
31+
fields: { opens_at__c: { type: 'time', label: 'Opens', defaultValue: '12:00Z' } },
32+
});
33+
34+
let warn: { mock: { calls: unknown[][] }; mockRestore(): void };
35+
beforeEach(() => {
36+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
37+
});
38+
afterEach(() => {
39+
warn.mockRestore();
40+
});
41+
42+
/** The `stored TYPE/NAME` subject of each conversion notice, in order. */
43+
const subjects = () => warn.mock.calls
44+
.map((c) => String(c[0]))
45+
.filter((line) => line.includes('carries a pre-protocol shape'))
46+
.map((line) => /stored (\S+) carries/.exec(line)?.[1]);
47+
48+
describe('[#29596] a stored conversion notice names the item by its identity', () => {
49+
it('two stale extension rows give two notices, each naming its target', () => {
50+
const h = host();
51+
const account = convertStoredItem.call(h, 'object_extension', staleExtension('crm_account'));
52+
const contact = convertStoredItem.call(h, 'object_extension', staleExtension('crm_contact'));
53+
54+
expect(subjects()).toEqual(['object_extension/crm_account', 'object_extension/crm_contact']);
55+
// The rows still convert, and the body is not given a `name`.
56+
expect(account).toEqual({ extend: 'crm_account', fields: { opens_at__c: { type: 'time', label: 'Opens', defaultValue: '12:00' } } });
57+
expect(contact).not.toHaveProperty('name');
58+
});
59+
60+
it('the same extension row read again warns once — the dedupe still holds per row', () => {
61+
const h = host();
62+
convertStoredItem.call(h, 'object_extension', staleExtension('crm_account'));
63+
convertStoredItem.call(h, 'object_extension', staleExtension('crm_account'));
64+
convertStoredItem.call(h, 'object_extension', staleExtension('crm_contact'));
65+
66+
expect(subjects()).toEqual(['object_extension/crm_account', 'object_extension/crm_contact']);
67+
});
68+
69+
it('CONTROL — a non-extension type is still named by its `name`, and a nameless body is still `<unnamed>`', () => {
70+
const h = host();
71+
const object = (name?: string) => ({
72+
...(name === undefined ? {} : { name }),
73+
fields: { opens_at: { type: 'time', defaultValue: '12:00Z' } },
74+
});
75+
convertStoredItem.call(h, 'object', object('shift'));
76+
convertStoredItem.call(h, 'object', object('rota'));
77+
convertStoredItem.call(h, 'object', object());
78+
79+
expect(subjects()).toEqual(['object/shift', 'object/rota', 'object/<unnamed>']);
80+
});
81+
82+
it('CONTROL — an `extend` key on another type does not name it', () => {
83+
const h = host();
84+
convertStoredItem.call(h, 'object', { extend: 'crm_account', fields: { opens_at: { type: 'time', defaultValue: '12:00Z' } } });
85+
86+
expect(subjects()).toEqual(['object/<unnamed>']);
87+
});
88+
});

‎packages/metadata-protocol/src/protocol-stored-item-conversion.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* `ObjectStackProtocolImplementation` method of the same name as it stood,
1414
* re-indented one level and otherwise unedited: the same `flow` exemption,
1515
* the same chain call, the same warn-once key and text, and the same page
16-
* default.
16+
* default. Since the move, the notice names the item by its list-slot
17+
* identity (`listSlotNameOf`), so a stored `object_extension` is named by its
18+
* target (#29596).
1719
*
1820
* ## How the moved bodies reach the class
1921
*
@@ -39,6 +41,7 @@ import { applyConversionsToStoredItem, type ConversionNotice, type ConversionTod
3941
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
4042
import type { ObjectStackProtocolImplementation } from './protocol.js';
4143
import { withDeclaredPageTypeDefault } from './protocol.js';
44+
import { listSlotNameOf } from './protocol-object-extension-item.js';
4245

4346
/**
4447
* The `ObjectStackProtocolImplementation` members the functions below read —
@@ -60,7 +63,10 @@ export function convertStoredItem(
6063
): unknown {
6164
const singular = PLURAL_TO_SINGULAR[type] ?? type;
6265
const converted = this.convertStoredItemDetailed(type, data, (n) => {
63-
const name = (data as { name?: unknown } | null | undefined)?.name;
66+
// [#29596] The item's identity, not its `name` key: an `object_extension`
67+
// body carries `extend` and no `name`, so reading `name` warned every
68+
// stored extension as `<unnamed>` and keyed them all to one notice.
69+
const name = listSlotNameOf(singular, data);
6470
const key = `${n.conversionId}|${singular}|${String(name ?? '')}`;
6571
if (this.storedConversionWarned.has(key)) return;
6672
this.storedConversionWarned.add(key);

‎packages/metadata/src/loaders/database-loader.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,45 @@ describe('DatabaseLoader', () => {
375375
});
376376
});
377377

378+
// [#29596] The stored-row conversion notice names a row by its row name,
379+
// the identity every type is stored under. An `object_extension` body
380+
// carries `extend` and no `name`, and its row is stored under its target,
381+
// so this seam (unlike the protocol's, which reads the body) already names
382+
// each stale extension and keys the warn-once set by it. Measured on a real
383+
// store: the two rows read here as `crm_account` and `crm_contact`.
384+
describe('stored conversion notice names the row (#29596)', () => {
385+
const subjects = (warn: { mock: { calls: unknown[][] } }) => warn.mock.calls
386+
.map((c) => String(c[0]))
387+
.filter((line) => line.includes('carries a pre-protocol shape'))
388+
.map((line) => /stored (\S+) carries/.exec(line)?.[1]);
389+
const staleTime = { type: 'time', label: 'Opens', defaultValue: '12:00Z' };
390+
391+
it('two stale extension rows give two notices, each naming its target', async () => {
392+
await loader.save('object_extension', 'crm_account', { extend: 'crm_account', fields: { opens_at__c: staleTime } });
393+
await loader.save('object_extension', 'crm_contact', { extend: 'crm_contact', fields: { opens_at__c: staleTime } });
394+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
395+
try {
396+
const items = await new DatabaseLoader({ driver: mockDriver }).loadMany<{ extend: string; fields: Record<string, { defaultValue: string }> }>('object_extension');
397+
expect(items.map((i) => i.fields.opens_at__c.defaultValue)).toEqual(['12:00', '12:00']);
398+
expect(subjects(warn)).toEqual(['object_extension/crm_account', 'object_extension/crm_contact']);
399+
} finally {
400+
warn.mockRestore();
401+
}
402+
});
403+
404+
it('CONTROL — a non-extension row is named by its row name the same way', async () => {
405+
await loader.save('object', 'shift', { name: 'shift', fields: { opens_at: staleTime } });
406+
await loader.save('object', 'rota', { name: 'rota', fields: { opens_at: staleTime } });
407+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
408+
try {
409+
await new DatabaseLoader({ driver: mockDriver }).loadMany('object');
410+
expect(subjects(warn)).toEqual(['object/shift', 'object/rota']);
411+
} finally {
412+
warn.mockRestore();
413+
}
414+
});
415+
});
416+
378417
describe('stat', () => {
379418
it('should return null for missing items', async () => {
380419
const stats = await loader.stat('object', 'missing');

0 commit comments

Comments
 (0)