Skip to content

Commit a4c11ad

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): derive the reference graph from the type schemas (#9190) (#9324)
`findReferencesToMeta` — the admin "Used by" panel behind `GET /api/v1/meta/:type/:name/references` — was driven by a hand-curated table of 7 target types and 40 dotted paths. Measured against the schemas it claimed to describe, 34 of the 40 named properties no metadata type declares, leaving 5 of its 7 target keys answering `{ references: [] }` unconditionally while appearing covered. The panel's empty state reads "Nothing in the metadata graph points at this item. Safe to delete." Coverage is now derived at boot from `DEFAULT_METADATA_TYPE_REGISTRY` and each type's schema, in the shape #7894 used for the URL-spelling map, so a newly declared type arrives covered. The unit of derivation is a PROPERTY, not a path: recursive containers (app navigation) make an exhaustive path list unbounded, and the walk reports where the name was actually found. No wire change: response shape, status codes and error envelope untouched. Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 326f5de commit a4c11ad

8 files changed

Lines changed: 1036 additions & 178 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
Derive the metadata reference graph from the type schemas instead of curating it by hand
6+
7+
`GET /api/v1/meta/:type/:name/references` — the admin "Used by" panel, rendered
8+
immediately before a rename or a delete — was driven by a hand-written table of
9+
seven target types and forty dotted paths. Measured against the schemas it was
10+
supposed to describe, **34 of those 40 paths named properties no metadata type
11+
declares**: `app.navItems[]` / `app.tabs[]` (the schema declares `navigation`
12+
and `areas`), `agent.tools[]` (removed in `@objectstack/spec` 17),
13+
`permission.objects[].name` (a name-keyed record, not an array),
14+
`object.fields{}.referenceTo` (the field property is `reference`),
15+
`dashboard.widgets[].view`, `page.viewName`, and every path the table listed for
16+
`flow`. Five of its seven target types therefore answered `{ references: [] }`
17+
unconditionally, on every deployment, while appearing to be covered — and an
18+
empty panel reads as "nothing depends on this, safe to delete".
19+
20+
Coverage is now derived at boot from `DEFAULT_METADATA_TYPE_REGISTRY` and each
21+
type's Zod schema, so a newly declared metadata type arrives covered instead of
22+
waiting for someone to remember it. Seventeen target types now resolve real
23+
reference sites, including `permission`-to-object grants (through the record
24+
key, which the old path grammar could not express), `translation`, `dataset`,
25+
`action`, `report`, `doc` and `datasource`, plus flow-node references such as
26+
`subflow`. References nested inside recursive containers — a view named from a
27+
third-level app navigation group — are found at any depth, which no finite path
28+
list could do.
29+
30+
No wire change: the response shape, status codes and error envelope are
31+
untouched. The `path` and `kind` values now describe where the reference was
32+
actually found rather than which table row matched.
33+
34+
Two gaps are deliberately declared rather than papered over: `external_catalog`
35+
resolves no schema, so its references are not computable and it is named in the
36+
derivation's `unwalkableSourceTypes` (pinned by a test, so the set cannot grow
37+
silently), and reference properties whose name does not spell their target —
38+
`FieldSchema.reference` is the one carried — need a producer-side annotation to
39+
become derivable.

packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,23 @@ describe('[#8896] searchAll — an object that could not be READ is not an objec
210210

211211
describe('[#8896] findReferencesToMeta — a source type that could not be READ is not a source type with no references', () => {
212212
/**
213-
* `view` has three matchers (`dashboard`, `app`, `page`), so a single
214-
* failing source type leaves the other two answering — which is exactly the
215-
* pre-fix trap: a SHORT list that looks complete. `page` carries a real
216-
* reference to `my_view`, so the healthy half is observable.
213+
* `view` is reachable from four source types (`app`, `object`, `page`,
214+
* `view`), so a single failing source type leaves the others answering —
215+
* which is exactly the pre-fix trap: a SHORT list that looks complete.
216+
* `page` carries a real reference to `my_view`, so the healthy half is
217+
* observable.
218+
*
219+
* [#9190] The fixture used to spell that reference `page.viewName`, which
220+
* `PageSchema` does not declare — it agreed with the hand-curated path
221+
* table, and the table was wrong. The real site is `view`, reached through
222+
* a `dataSource`, and the derived walk finds it wherever the document puts
223+
* it rather than at one memorised path.
217224
*/
218-
const pageReferencingTheView = { name: 'home_page', label: 'Home', viewName: 'my_view' };
225+
const pageReferencingTheView = {
226+
name: 'home_page',
227+
label: 'Home',
228+
slots: { header: { dataSource: { view: 'my_view' } } },
229+
};
219230

220231
function engineWhereTypeFails(failingType: string | null, error?: unknown) {
221232
const typeReads: string[] = [];
@@ -239,18 +250,24 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ
239250
const result = await protocol.findReferencesToMeta({ type: 'view', name: 'my_view' });
240251

241252
expect(result.references).toEqual([
242-
{ type: 'page', name: 'home_page', label: 'Home', path: 'viewName', kind: 'page' },
253+
{
254+
type: 'page',
255+
name: 'home_page',
256+
label: 'Home',
257+
path: 'slots.header.dataSource.view',
258+
kind: 'page view',
259+
},
243260
]);
244-
// All three source types were really consulted — this is what makes
245-
// "one of them failed" a meaningful condition below.
246-
expect(typeReads).toContain('dashboard');
261+
// Every source type that can name a view was really consulted — this is
262+
// what makes "one of them failed" a meaningful condition below.
247263
expect(typeReads).toContain('app');
264+
expect(typeReads).toContain('object');
248265
expect(typeReads).toContain('page');
249266
});
250267

251268
it('a source type whose read FAILS fails the whole scan, envelope intact', async () => {
252269
const injected = connectionDropped();
253-
const { engine, typeReads } = engineWhereTypeFails('dashboard', injected);
270+
const { engine, typeReads } = engineWhereTypeFails('app', injected);
254271
const protocol = new ObjectStackProtocolImplementation(engine as never);
255272

256273
const caught = await rejection(
@@ -267,7 +284,7 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ
267284
expect(ErrorCode.safeParse(caught.code).success).toBe(true);
268285
// The driver's own error is not lost — it rides as `cause`.
269286
expect(caught.cause).toBe(injected);
270-
expect(typeReads).toContain('dashboard');
287+
expect(typeReads).toContain('app');
271288
// Pre-fix this resolved `{ references: [ …the page hit… ] }` — one real
272289
// reference presented as the complete dependency list, which an admin
273290
// reads as "safe to delete".
@@ -286,7 +303,7 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ
286303
expect(result.references).toEqual([]);
287304
});
288305

289-
it('a target type absent from REFERENCE_PATHS still returns an empty list without reading anything', async () => {
306+
it('a target type with no derived reference site still returns an empty list without reading anything', async () => {
290307
const { engine, typeReads } = engineWhereTypeFails(null);
291308
const protocol = new ObjectStackProtocolImplementation(engine as never);
292309

@@ -300,15 +317,21 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ
300317
// The benign discrimination lives in `getMetaItems`, one layer down —
301318
// this seam inherits it rather than repeating it, and this pin is what
302319
// proves the inheritance still holds through the removed `catch`.
303-
const { engine, typeReads } = engineWhereTypeFails('dashboard', tableNotProvisioned('sys_metadata'));
320+
const { engine, typeReads } = engineWhereTypeFails('app', tableNotProvisioned('sys_metadata'));
304321
const protocol = new ObjectStackProtocolImplementation(engine as never);
305322

306323
const result = await protocol.findReferencesToMeta({ type: 'view', name: 'my_view' });
307324

308325
expect(result.references).toEqual([
309-
{ type: 'page', name: 'home_page', label: 'Home', path: 'viewName', kind: 'page' },
326+
{
327+
type: 'page',
328+
name: 'home_page',
329+
label: 'Home',
330+
path: 'slots.header.dataSource.view',
331+
kind: 'page view',
332+
},
310333
]);
311334
// Proof the benign branch was actually EXERCISED.
312-
expect(typeReads).toContain('dashboard');
335+
expect(typeReads).toContain('app');
313336
});
314337
});

packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ function makeStubEngine() {
122122
getPackage: () => undefined,
123123
registerItem: () => {},
124124
registerObject: () => {},
125+
// [#9190] `getMetaItems({ type: 'app' })` decorates each app with
126+
// its contributed nav groups. The double gained this the day a
127+
// reference scan first read `app` — identity is the right stub,
128+
// because the fold under test is about the TYPE KEY a read used,
129+
// not about nav contribution.
130+
applyNavContributions: (app: unknown) => app,
125131
},
126132
};
127133
return { engine, tables, reads, items };
@@ -434,37 +440,49 @@ describe('#9157 — findReferencesToMeta', () => {
434440
});
435441

436442
it('CONTROL: a manifest-PRESENT plural still resolves its real dependents', async () => {
443+
// [#9190] The fixture moved from `dashboard.widgets[].view` to
444+
// `app.navigation[].viewName`, and the move is the point rather than a
445+
// detail: `DashboardSchema` declares no `view` property anywhere, so
446+
// the old fixture agreed with the old hand-curated path table and BOTH
447+
// described a document the platform cannot store. `AppSchema.navigation`
448+
// is real, and the site the walk uses for it is derived from that
449+
// schema.
437450
const { engine, items } = makeStubEngine();
438-
items.dashboard = [{ name: 'sales_dash', label: 'Sales', widgets: [{ id: 'w1', view: 'all_leads' }] }];
451+
items.app = [{ name: 'sales_app', label: 'Sales', navigation: [{ viewName: 'all_leads' }] }];
439452
const p = new ObjectStackProtocolImplementation(engine);
440453

441454
const res = await p.findReferencesToMeta({ type: PRESENT_PLURAL, name: 'all_leads' });
442455

443456
expect(res.references).toEqual([
444-
{ type: 'dashboard', name: 'sales_dash', label: 'Sales', path: 'widgets[].view', kind: 'dashboard widget' },
457+
{ type: 'app', name: 'sales_app', label: 'Sales', path: 'navigation[].viewName', kind: 'app viewName' },
445458
]);
446459
});
447460

448-
it('the manifest-ABSENT class is NOT closed here, and that is stated rather than implied', async () => {
449-
// ⚠️ Honest scope pin. The card's `translations` example claims this verb
450-
// answers `{ references: [] }` for a manifest-absent type — true, and the
451-
// fold does not change it: every `REFERENCE_PATHS` key (`object`, `view`,
452-
// `tool`, `skill`, `flow`, `dashboard`, `page`) is manifest-PRESENT, so
453-
// `translation` has no registry entry either. This method's own doc calls
454-
// an unregistered target a legitimate no-hit rather than an error.
461+
it('[#9190] the manifest-ABSENT residue #9157 pinned here is CLOSED, and both spellings reach the same real hits', async () => {
462+
// ⚠️ This pin has MOVED, deliberately. #9157 asserted that
463+
// `translation` answers `{ references: [] }` whichever spelling you use
464+
// — true then, because the hand-curated table had no `translation` key
465+
// and this method's doc called that a legitimate no-hit. #9190 closed
466+
// it the way the ruling required: by DERIVATION, not by adding a key.
467+
// `DocSchema.translations` is a real, schema-declared reference site, so
468+
// the walk finds it without anyone having written `translation` down.
455469
//
456-
// Closing it is a `REFERENCE_PATHS` COVERAGE question, not a spelling
457-
// one, and it is a different card. Asserted so a reader cannot over-read
458-
// this PR's claim, and so the day `translation` gains a matcher this
459-
// test goes red and asks to be re-read.
460-
const { engine } = makeStubEngine();
470+
// What #9157 owns is UNCHANGED and is what this test still proves: the
471+
// two spellings fold to one answer. What changed is that the answer is
472+
// no longer vacuously empty, so the test can prove the fold on a
473+
// non-trivial result — which is a stronger assertion than the empty one
474+
// it replaces.
475+
const { engine, items } = makeStubEngine();
476+
items.doc = [{ name: 'intro', label: 'Intro', translations: { greeting: { title: 'Hallo' } } }];
461477
const p = new ObjectStackProtocolImplementation(engine);
462478

463479
const viaPlural = await p.findReferencesToMeta({ type: OVERLAY_ABSENT_PLURAL, name: 'greeting' });
464480
const viaCanonical = await p.findReferencesToMeta({ type: OVERLAY_ABSENT_TYPE, name: 'greeting' });
465481

466-
expect(viaPlural.references).toEqual([]);
467-
expect(viaCanonical.references).toEqual([]);
482+
expect(viaCanonical.references).toEqual([
483+
{ type: 'doc', name: 'intro', label: 'Intro', path: 'translations{key}', kind: 'doc translations' },
484+
]);
485+
expect(viaPlural.references).toEqual(viaCanonical.references);
468486
});
469487

470488
it('CONTROL: a spelling that reaches for no declared type is served, not refused', async () => {

0 commit comments

Comments
 (0)