Skip to content

Commit 6175da8

Browse files
os-warrenclaude
andauthored
feat(spec): declare IMetadataService.loadManyKeyed beside its plural-read siblings (#19609)
Fixes #15385 Declares `loadManyKeyed?` on `IMetadataService`, deletes the local structural type the ObjectQL governance audit used to reach it, and documents the member. Execution of the recorded ruling — director seat, decision batch #123 item 5, 2026-09-12, comment `5644711080`, maintainer verbatim 「同意」. That ruling picks **option 1** of the two the card put to triage and enumerates four items; all four are below. Option 2 (leave it undeclared on purpose) is ruled out and is not re-opened here. Clause-②: yes ## The declaration `packages/spec/src/contracts/metadata-service.ts` — the new member sits immediately after its unkeyed twin `loadMany?` (line 675 on the base commit), inside the same `IMetadataService` declaration that already carries `loadMany?` and `loadDiagnosed?`. ⚠️ The signature is spelled in words here **on purpose**: this surface deletes tag-shaped tokens, generics included, from prose and from code fences alike, so a literal copy of it would arrive mutilated. `loadManyKeyed?` is optional, is generic in one parameter `T` that defaults to `unknown`, takes `type: string` plus an optional `options` bag typed as a Record from string to unknown, and resolves to an Array of `{ name: string; data: T }` pairs. That is the signature the ruling names, member for member. **The diff is the authority on it, not this paragraph.** It is **optional**, like both siblings, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof ... === 'function'` probe stays the way a caller asks for it. ### Why an inline pair shape rather than the published `MetadataKeyedItem` The ruling writes the return type inline, and that is also the only spelling available. `MetadataKeyedItem` is declared in `packages/metadata/src/loaders/loader-interface.ts` and exported from `@objectstack/metadata`, which **depends on** `@objectstack/spec`; `packages/spec` declares no workspace dependency at all (`pg-connection-string` and `zod`). Importing the named type here would invert that edge and close a cycle. The inline pair is also the file's own precedent: `loadDiagnosed?` declares its result inline in exactly the same way. The two shapes are structurally identical — `MetadataKeyedItem` is `readonly name: string` beside `readonly data: T`, and a readonly property is assignable to a mutable one — so `MetadataManager implements IMetadataService` keeps compiling with no edit to `packages/metadata`, which is what the green build below shows. ## The four ruled items 1. **Declared** — as above, with a docblock citing this ruling the way `loadDiagnosed`'s cites `#4127 batch 4`. 2. **Local structural type deleted** — `packages/objectql/src/plugin.ts` loses `KeyedPluralMetadataRead` (the type and its docblock), and the three service lookups in `resolveGovernanceMetadataService` now ask for `IMetadataService` alone instead of intersecting it. Occurrences of that type name under `packages/` go **4 to 0** — 1 declaration plus 3 use sites; an earlier draft of this line said 3, corrected against the blob by the at-tier review (lit control: `IMetadataService` in the same file = 13, so the zero is a reading). `check:slot-lookup` stays green — see below. 3. **Docs** — `content/docs/kernel/contracts/metadata-service.mdx` gains the member, in the interface excerpt's `Loader reads (optional)` group and as a new `loadManyKeyed` subsection. Section choice and a contrary fact about it are in the acceptance notes. 4. **Carriers** — `Clause-②: yes` above; `minor` changeset (`@objectstack/spec`, whose changesets `fixed` group already carries `@objectstack/objectql`). The `needs:contract-review` carrier was **not** hung by this branch — the dev never wrote a label. ⚠️ **Corrected provenance:** the owning seat hung it on 2026-09-21T16:58:44Z, after the dev's push, which the PR's own event log records; a reader checking the labels today will find it present. ⛔ The earlier "see the acceptance notes" pointer is dropped: those notes never mentioned the carrier. ## Round 2 — head `b96baa08f2` (2 files, +38 / −13) Three corrections, all prose; the PR's file list is unchanged at 5 and no new path was pulled in. 1. **The dangling citation.** `check:issue-citations` was RED at `97a639c5b3`: the new docblock cited an issue that returns **404** (LIT CONTROL: its neighbour `#14424` → 200, so the 404 is a reading). ⭐ The replacement was **not guessed** — `#15378` was verified four ways before being named: HTTP 200, `merged: true`, `merged_at`, base `main`, and the depth-immune one — `origin/main` **holds the implementation it added** (`git grep 'async loadManyKeyed' origin/main -- packages/metadata/src/metadata-manager.ts` = 1; nonsense control = 0). ⚠️ **The gate's own remedy arm two does not work**, and this is filed as **#19614**: keeping the `#` still matches `CITATION_RE`, and `NON_CITATION_HEADS` excuses only ordinal heads — there is no prose-acknowledgement mechanism in the script. So the dead card is kept as **bare digits without a leading hash**, with a sentence saying why. Greppable, and nothing dangles. 2. **The docs example taught what its own Callout rejects.** It probed nothing and null-coalesced to `[]`, turning absence into an empty set — on the one member whose reason for existing is that silent drops are dangerous. It now reads the member into a local, guards on `typeof === 'function'`, and its `else` branch says why absence is not emptiness, matching `plugin.ts`. `?? []` is gone from the page (0 hits). 3. **A name that named nothing.** "customization container" had **0** hits on `origin/main` across `packages/`, `content/` and `docs/`. The right vocabulary came from `MetadataKeyedItem`'s own docblock: an **aggregated `defineView` container** "has no own `name` BY DESIGN (its identity is the target object)". Both carriers now say that, each with an explicit disclaimer that it is **not** the ADR-0005 `sys_metadata` org customization overlay — which this same page documents separately. **Gate readings at `b96baa08f2`** — ⚠️ `check:issue-citations` is recorded here and ⛔ not in the derived-families row (which is **below**, in the Local runs table — an earlier draft of this line said "above"), because its root script is **`--self-test` only** while CI runs the self-test *and* the scan; reporting the alias as a pass is what produced the red in the first place. Run as the **SCAN**: `node scripts/check-issue-citations.mjs --base origin/main` → **EXIT=0**, captured before any pipe, re-run at the final head → EXIT=0 (4 citations judged across 13 files; 2 resolves, 2 resolves-as-pull-request). `pnpm lint` whole repo EXIT=0 · spec build success · `check:generated` all 15 up to date against a fresh build · spec typecheck pass · spec test 509 files / 14901 passed · objectql typecheck EXIT=0 · `check:slot-lookup` holds · `check:nul-bytes` OK plus a hand control-character scan of both edited files. **Mechanical proof the `.ts` edit is docblock-only:** every added and removed line in `git diff 97a639c..b96baa0 -- packages/spec/src/contracts/metadata-service.ts` is a comment line — zero non-comment lines. No type or runtime surface moved, so the ablation recorded at `97a639c5b3` still stands and was not re-run. ⚠️ **Two prerequisite failures, resolved rather than reported as passes**, both artefacts of a fresh worktree and neither about the diff: `check:docs-transcript-drift` exit 3 (`@objectstack/lint` unbuilt) → built, re-ran, EXIT=0; objectql typecheck first exit 2 with 42 errors, **all** `TS2307 Cannot find module` from an unbuilt dependency closure → built, re-ran, EXIT=0. ⚠️ **A negative reading deliberately NOT relied on:** `git merge-base --is-ancestor` on #15378's squash commit exited 1, but this checkout is shallow and the control leg was a shallow-window near-relative — so that negative is **void, not evidence**. The tree read and the API's `merged` / `merged_at` answer the question without a history walk. ## Verification Reverse verification, because this is a cross-package type change and a green typecheck against a stale `.d.ts` is indistinguishable from a real one. Run from the committed state through `scripts/ablation-replace.mjs`, with the on-disk and in-`dist` evidence the tool produces: - **Mutate** — the declared member renamed at its anchor. Anchor hits 1 to 0, blob `bd37483b1715` to `3a9e85d219ad`. - **Reached the artifact** — `scripts/ablation-dist-preflight.mjs` found the mutated marker in 2 built files (`packages/spec/dist/contracts/index.d.ts` and `.d.mts`), so the run below read the rebuilt declarations and not a cache. - **The ablation run** — `tsc --noEmit` in `packages/objectql` went red with **exactly one** error, and it is the call site: `src/plugin.ts(2593,35): error TS2339: Property 'loadManyKeyed' does not exist on type 'IMetadataService'.` - **Restore** — blob back to `bd37483b1715`, equal to HEAD, `git diff HEAD` empty, whole-tree `git status --porcelain` empty. After a rebuild the mutated marker is gone from `dist` (0 occurrences) and the real member is back (2), and `tsc --noEmit` in `packages/objectql` is green with zero output. That is the proof for ruled item 2: the call site now reads the contract, and it reads *only* the contract. Local runs. ⚠️ **Provenance corrected — this table is not all from one head.** The nine readings restated in the Round 2 section were taken at the final head `b96baa08f2`; every other row here — the objectql typecheck, the changeset gates, the 14 docs gates and the 20 further derived families — was measured at `97a639c5b3`, **before** round 2 rewrote the `.mdx`. ⛔ Nothing is actually unmeasured at the final head: CI ran the whole docs family green there, including "`packages/spec/src/**` doc-block symbol anchors resolve". It is the sentence that over-claimed its own provenance, not the work. | check | result | |:--|:--| | `pnpm lint` (whole repo, `eslint . --no-inline-config`) | **0** — clean | | `pnpm --filter @objectstack/spec build` | success; 34/34 declaration files emitted | | `pnpm --filter @objectstack/spec check:generated` | **All 15 generated artifacts up to date** | | `pnpm --filter @objectstack/spec typecheck` | pass (includes `check:test-typecheck`) | | `pnpm --filter @objectstack/spec test` | 509 files, **14901 passed**, 1 todo | | `pnpm --filter @objectstack/objectql typecheck` | pass | | `pnpm --filter @objectstack/objectql test` | 303 files, **5050 passed** | | `pnpm check:slot-lookup` | ✓ holds — 106 unswept sites in 25 files, **none new**, baseline key set verified against `0e658fb`: no files added | | `pnpm check:nul-bytes` | ✓ 9156 text files scanned, no raw control bytes | | changeset gates (`check:empty-changeset`, `check:adr-0087-registration`, `check:changeset-no-major`, `check:changeset-gate-self-tests`) | pass | | docs gates (`check:doc-anchors`, `check:doc-authoring`, `check:doc-frontmatter`, `check:docs-section-name`, `check:docs-single-h1`, `check:docs-redirects`, `check:docs-spec-enumerations`, `check:docs-transcript-drift`, `check:docs-audit-scope`, `check:doc-route-spelling`, `docs-audit/check-affected-docs`, `docs-audit/check-drift-comment`, `check:section-landing-index`, `check:keyed-text-bounds`) | pass | | further derived families run (`check:type-check-coverage`, `check:test-source-alias`, `check:published-files`, `check:dts-closure`, `check:lean-entry-closure`, `check:cross-package-test-inputs`, `check:spec-docblock-symbol-anchors`, `check:comment-mask-adoption`, `check:comment-mask-corpus`, `check:undeclared-dep-imports`, `check:query-options-erasure`, `check:spec-parsed-alias`, `check:objectql-double-limit`, `check:engine-double-contract`, `check:durability-log-level`, `check:published-readme-links`, `check:pm-prior-rulings`, `check:sourcemap-no-sources-content`, `check:strictness-ledger`, `check:skill-refs`) | pass | | `check:type-check-debt`, `check:dual-build-cjs-loads` | **NOT MEASURED** — both exited 3 (`PREREQUISITE NOT MET`); each needs a whole-workspace build this branch did not run. Neither a pass nor a finding. Declared to CI. | ### What the generators actually moved: nothing Measured rather than inferred, and this was the one prediction worth testing. Six generators were run against the built tree — `gen:api-surface`, `gen:export-origins`, `gen:spec-changes`, `gen:schema`, `gen:docs`, `gen:declaration-map` — each exiting 0, after which `git status --porcelain` listed **no** generated artefact. `check:generated` independently reports all 15 up to date. So an optional member on a published interface moves none of the four artefacts that name `IMetadataService`, exactly as the claim predicted. ⚠️ One reading on the way there was **not** a finding and should not be read as one: `check:api-surface` first reported stale with `PREREQUISITE NOT MET — this gate reads built output, and what is on disk predates the sources`. The `dist` had been built before a later edit to the test file, which is a build input. Rebuilding cleared it. It was never an artefact move. ## Acceptance notes ⛔ Noted, not filed, and deliberately **not** fixed here — each is outside this card's ruled four items. - **The docs page documents `loadManyKeyed` ahead of its own declared sibling `loadMany?`.** On `content/docs/kernel/contracts/metadata-service.mdx`, `loadMany` appeared **0** times before this change (lit control on the same page: `loadDiagnosed` = 5, so the zero is a reading). The page's interface excerpt is explicitly partial and says so — its line 29 points at `IMetadataService` in the source for the full member list — so this is a documentation gap rather than a contradiction, but the ordering is odd for a reader and it is being handed to the seat to file as its own card. Widening this PR to also document `loadMany` was declined on purpose. - **Section choice, and why.** The new member is documented as a `loadManyKeyed` subsection under `Core CRUD`, immediately after `load / loadDiagnosed` and before `list / listNames`. `Bulk Operations` was considered and rejected: despite the name, that section on this page documents bulk **writes** (`bulkRegister` / `bulkUnregister`), so a plural *loader read* filed there would sit in the write section. The chosen spot is the page's loader-read run, one step from the plural registry reads a reader would be comparing it against. - **The page's own `loadDiagnosed` example still teaches the shape this PR's new example refuses.** At `content/docs/kernel/contracts/metadata-service.mdx:138`, two sections above the new probe-first example, the pre-existing `loadDiagnosed` snippet spells an optional call plus `?? {}` — absence collapsing into a value, which is exactly what the new example's `else` branch says not to do ("Do NOT fall through to an empty set") and what the info Callout restates ("never as an empty set"). It is **present at this PR's merge base and untouched here** (`?? []` on this page at head: **0**, git grep exit 1 captured before any pipe; lit control `?? {}` on the same page: **1**, at `:138`, so the zero is a reading). The page is now internally inconsistent in style rather than wrong. ⛔ Recorded here rather than filed as a card, per the standing rule: the question "which PR will touch this file?" has an answer, and it is this one — so the note belongs where the next editor of the page will read it. Widening this PR to rewrite a snippet outside its four ruled items was declined on purpose. - **The `#16090` serialisation caveat recorded in the ruling's item 3 is spent.** That issue is closed, and no open pull request holds the page. Nothing was serialised against and nothing waited. ## Landing ⛔ Draft on purpose, and it stays that way from this branch. No flip to ready, no enqueue, no auto-merge. Landing is the owning seat's act after an at-tier contract review. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b4022b commit 6175da8

5 files changed

Lines changed: 225 additions & 27 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`IMetadataService` declares `loadManyKeyed?` — the keyed plural loader read now sits on the contract beside its two declared siblings `loadMany?` and `loadDiagnosed?` (#15385).
6+
7+
Clause-②: yes
8+
9+
A verb family lives whole on the contract. `MetadataManager.loadManyKeyed(type)` shipped as a public member with no declaration on the interface its siblings are declared on, so the one cross-package caller — the ObjectQL governance audit — narrowed the service slot with a **local structural type** written beside the call site. That local type is deleted in the same change and the call site reads the contract.
10+
11+
The vocabulary is not new: `loadManyKeyed`, and the `{ name, data }` item shape it answers with, are already published on `MetadataLoader`, which declares the same member as optional over its own loader-local options type. What this adds is the member's place on `IMetadataService`.
12+
13+
```ts
14+
loadManyKeyed?<T = unknown>(
15+
type: string,
16+
options?: Record<string, unknown>,
17+
): Promise<Array<{ name: string; data: T }>>;
18+
```
19+
20+
**What it is for.** The key is a fact about the **store** — `register()`'s own `name` argument — and it travels *beside* `data`, never folded into it, so `data` stays byte-identical to what the unkeyed plural read would return and no consumer ever sees a synthesised `name`. An item whose stored body has no top-level `name` is legal and deliberate (an org customization container's identity is the object it targets), and such an item has no identity at all in a plural read keyed by `data.name` — it is dropped, silently. That is why this is a second member rather than a widened return type on the existing one.
21+
22+
**What moves for consumers.** Nothing breaks. The member is **optional**, like `loadMany?` and `loadDiagnosed?` beside it, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof … === 'function'` probe stays the way a caller asks for it. What changes is that a caller no longer has to declare the shape itself to stay typed: intersecting the slot with a hand-written structural type was the only way to reach the member without erasing the lookup to `any`, and that workaround is now unnecessary. `MetadataManager`, which already implements the member, needs no edit.
23+
24+
This is the position `loadDiagnosed` was in before #4127 batch 4 declared it, and it is resolved the same way. Ruled in decision batch #123 item 5 (2026-09-12), maintainer verbatim: 「同意」.
25+
26+
`content/docs/kernel/contracts/metadata-service.mdx` gains the member in the same change.

‎content/docs/kernel/contracts/metadata-service.mdx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface IMetadataService {
5151
load?<T>(type: string, name: string, options?): Promise<T | null>;
5252
loadDiagnosed?<T>(type: string, name: string, options?):
5353
Promise<{ data: T | null; degraded: boolean; errors: string[] }>;
54+
loadManyKeyed?<T>(type: string, options?): Promise<Array<{ name: string; data: T }>>;
5455

5556
// Convenience accessors for UI metadata (optional)
5657
getView?(name: string): Promise<unknown | undefined>;
@@ -140,6 +141,55 @@ if (degraded) {
140141
}
141142
```
142143

144+
### loadManyKeyed
145+
146+
Optional. Reads **every** item of a type through the registered loaders, each
147+
one paired with the key its loader holds it under.
148+
149+
The key is a fact about the **store**, not about the body — it is `register`'s
150+
own `name` argument — and it travels *beside* `data`, never inside it. `data` is
151+
exactly what the unkeyed `loadMany` would return for the same item, so no
152+
consumer ever sees a synthesised `name`.
153+
154+
That difference is the point. A stored body with no top-level `name` is legal
155+
and deliberate — an aggregated `defineView` container has none **by design**,
156+
because its identity is the object it targets — and such an item has **no
157+
identity at all** in a plural read keyed by `data.name`: it is dropped,
158+
silently. Reach for the keyed read wherever the set you are assembling is
159+
addressed by key.
160+
161+
<Callout type="warn">
162+
Not to be confused with the **org customization overlay**, which is ADR-0005's
163+
`sys_metadata` mechanism described under "Overlay Management" below — a
164+
different thing entirely.
165+
</Callout>
166+
167+
Probe before you call, and treat an absent member as *absent*, never as an empty
168+
result — this is the same distinction `loadDiagnosed` exists for, one member
169+
over:
170+
171+
```typescript
172+
const keyedRead = metadataService.loadManyKeyed;
173+
174+
if (typeof keyedRead === 'function') {
175+
for (const { name, data } of await keyedRead.call(metadataService, 'action')) {
176+
// `name` is the key the store holds this item under — present even when
177+
// `data` carries none of its own.
178+
}
179+
} else {
180+
// This plane offers no keyed read. ⛔ Do NOT fall through to an empty set:
181+
// "no keys available here" and "the store holds nothing" are different
182+
// facts. Fall back to the unkeyed plural read, or record that keys could
183+
// not be obtained.
184+
}
185+
```
186+
187+
<Callout type="info">
188+
Probe it like every optional member —
189+
`typeof metadataService.loadManyKeyed === 'function'`. A metadata plane that
190+
does not offer it is read as "no keyed read here", never as an empty set.
191+
</Callout>
192+
143193
### list / listNames
144194

145195
The plural reads' failure posture. Both read a **set** through the same

‎packages/objectql/src/plugin.ts‎

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -55,27 +55,6 @@ interface ProtocolWithDbRestore {
5555
}>;
5656
}
5757

58-
/**
59-
* [#14423] The keyed plural read the governance audit reads the metadata plane
60-
* through — `MetadataManager.loadManyKeyed(type)`, structurally.
61-
*
62-
* Declared HERE, beside the one call site, rather than on `IMetadataService`:
63-
* `packages/spec` is a contract surface owned by another lane, and widening it
64-
* is its own decision with its own review. This is the same position
65-
* `loadDiagnosed` was in before it was declared — the call site and the
66-
* implementation agreed, and the contract was what nobody had written — and
67-
* the same remedy applies when that lane takes it: delete this and read the
68-
* contract. ⛔ Not `any`: intersecting the slot's real contract keeps the
69-
* lookup typed (#4251), and the member stays optional so a plane that predates
70-
* it type-checks and is simply read as "no keyed read here".
71-
*/
72-
type KeyedPluralMetadataRead = {
73-
loadManyKeyed?<T = unknown>(
74-
type: string,
75-
options?: Record<string, unknown>,
76-
): Promise<Array<{ name: string; data: T }>>;
77-
};
78-
7958
/** Type guard — checks whether the service exposes `loadMetaFromDb`. */
8059
function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
8160
return (
@@ -2555,14 +2534,11 @@ export class ObjectQLPlugin implements Plugin {
25552534
*/
25562535
private async resolveGovernanceMetadataService(
25572536
ctx: PluginContext,
2558-
): Promise<(IMetadataService & KeyedPluralMetadataRead) | undefined> {
2537+
): Promise<IMetadataService | undefined> {
25592538
const scopeId = this.environmentId;
25602539
if (scopeId && typeof ctx.getServiceScoped === 'function') {
25612540
try {
2562-
const scoped = await ctx.getServiceScoped<IMetadataService & KeyedPluralMetadataRead>(
2563-
'metadata',
2564-
scopeId,
2565-
);
2541+
const scoped = await ctx.getServiceScoped<IMetadataService>('metadata', scopeId);
25662542
if (scoped != null) return scoped;
25672543
} catch {
25682544
// Not resolvable under a scope on this host — nothing registered under
@@ -2573,7 +2549,7 @@ export class ObjectQLPlugin implements Plugin {
25732549
}
25742550
}
25752551
try {
2576-
return ctx.getService<IMetadataService & KeyedPluralMetadataRead>('metadata');
2552+
return ctx.getService<IMetadataService>('metadata');
25772553
} catch {
25782554
return undefined;
25792555
}

‎packages/spec/src/contracts/metadata-service.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,4 +514,112 @@ describe('Metadata Service Contract', () => {
514514
expect(params).toEqual({});
515515
});
516516
});
517+
518+
// ==========================================
519+
// Keyed plural loader read (#15385 batch #123 item 5)
520+
// ==========================================
521+
522+
describe('loadManyKeyed (optional member)', () => {
523+
/** A minimal base implementation with only the REQUIRED members. */
524+
const baseService = (): IMetadataService => ({
525+
register: async () => {},
526+
get: async () => undefined,
527+
list: async () => [],
528+
unregister: async () => {},
529+
exists: async () => false,
530+
listNames: async () => [],
531+
getObject: async () => undefined,
532+
listObjects: async () => [],
533+
});
534+
535+
/**
536+
* A stored body with NO top-level `name` — the shape the whole member
537+
* exists for. Its identity is the key the store holds it under, so keying
538+
* the unkeyed plural read by `data.name` has nothing to key it by.
539+
*/
540+
const namelessBody = { label: 'Account overrides', fields: [] as unknown[] };
541+
542+
it('is optional — an implementation without it still satisfies the contract', () => {
543+
const service = baseService();
544+
545+
// The optional-member convention: consumers probe before they call.
546+
expect(typeof service.loadManyKeyed).toBe('undefined');
547+
expect(typeof (service as IMetadataService).loadManyKeyed === 'function').toBe(false);
548+
});
549+
550+
it('is probeable with typeof === "function" when provided', () => {
551+
const service: IMetadataService = {
552+
...baseService(),
553+
loadManyKeyed: async () => [],
554+
};
555+
556+
expect(typeof service.loadManyKeyed).toBe('function');
557+
});
558+
559+
it('answers (key, body) pairs, and an empty set for a type nothing holds', async () => {
560+
const service: IMetadataService = {
561+
...baseService(),
562+
// Generic, because the declaration is: a double holding one fixed body
563+
// can only answer under the `T` its CALLER names.
564+
loadManyKeyed: async <T = unknown>(type: string) =>
565+
type === 'customization' ? [{ name: 'account', data: namelessBody as T }] : [],
566+
};
567+
568+
const keyed = await service.loadManyKeyed!<typeof namelessBody>('customization');
569+
expect(keyed).toEqual([{ name: 'account', data: namelessBody }]);
570+
571+
expect(await service.loadManyKeyed!('no_such_type')).toEqual([]);
572+
});
573+
574+
it('carries the key BESIDE the body — nothing is folded into `data`', async () => {
575+
const service: IMetadataService = {
576+
...baseService(),
577+
loadManyKeyed: async <T = unknown>() => [{ name: 'account', data: namelessBody as T }],
578+
};
579+
580+
const keyed = await service.loadManyKeyed!<typeof namelessBody>('customization');
581+
582+
// The body is the same object the unkeyed read would have returned...
583+
expect(keyed[0]!.data).toBe(namelessBody);
584+
// ...so the key lives only on the pair, never synthesised into the body.
585+
expect('name' in keyed[0]!.data).toBe(false);
586+
expect(keyed[0]!.name).toBe('account');
587+
});
588+
589+
it('types the key as string and the body as T', async () => {
590+
type Overrides = { label: string; fields: unknown[] };
591+
592+
const service: IMetadataService = {
593+
...baseService(),
594+
loadManyKeyed: async <T = unknown>() => [{ name: 'account', data: namelessBody as T }],
595+
};
596+
597+
// Type-level shape assertion: these annotations only compile against the
598+
// declared `Promise<Array<{ name: string; data: T }>>`.
599+
const keyed = await service.loadManyKeyed!<Overrides>('customization');
600+
const name: string = keyed[0]!.name;
601+
const body: Overrides = keyed[0]!.data;
602+
603+
expect(name).toBe('account');
604+
expect(body.label).toBe('Account overrides');
605+
});
606+
607+
it('takes the same engine-local options bag as its unkeyed sibling', async () => {
608+
let seen: Record<string, unknown> | undefined;
609+
const service: IMetadataService = {
610+
...baseService(),
611+
loadManyKeyed: async (_type: string, options?: Record<string, unknown>) => {
612+
seen = options;
613+
return [];
614+
},
615+
};
616+
617+
await service.loadManyKeyed!('customization', { patterns: ['*.json'] });
618+
expect(seen).toEqual({ patterns: ['*.json'] });
619+
620+
// `options` is optional — the one in-repo caller passes nothing.
621+
await service.loadManyKeyed!('customization');
622+
expect(seen).toBeUndefined();
623+
});
624+
});
517625
});

‎packages/spec/src/contracts/metadata-service.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,44 @@ export interface IMetadataService {
674674
*/
675675
loadMany?<T = unknown>(type: string, options?: Record<string, unknown>): Promise<T[]>;
676676

677+
/**
678+
* Load EVERY item of a type across all loaders, each paired with the KEY
679+
* its loader holds it under — the keyed twin of {@link loadMany}.
680+
*
681+
* `name` is the STORE's key (`register()`'s `name` argument), carried
682+
* BESIDE the body and never folded into it: `data` is exactly what
683+
* {@link loadMany} would return for the same item, so no consumer ever sees
684+
* a synthesised name and the register contract's `data.name` check keeps
685+
* meaning what it means. That is the whole reason this is a second member
686+
* rather than a widened {@link loadMany} return: an item whose stored body
687+
* has no top-level `name` — an aggregated `defineView` container, which has
688+
* none BY DESIGN because its identity is the object it targets — has no
689+
* identity at all in the unkeyed read, and keying that read by `data.name`
690+
* drops it. (⛔ Not the org customization overlay, which is ADR-0005's
691+
* `sys_metadata` mechanism and a different thing entirely.)
692+
*
693+
* [#15385 batch #123 item 5] Declared alongside {@link loadMany} /
694+
* {@link loadDiagnosed} — the position {@link loadDiagnosed} was in before
695+
* #4127 batch 4, and resolved the same way. Implemented by
696+
* `MetadataManager` in #15378 and reached by the ObjectQL governance audit
697+
* through a local structural type beside its one call site, with the
698+
* contract the only thing nobody had written. ⚠️ #15378 is the LIVE record
699+
* for that landing: the card it was filed under — issue 14423, written
700+
* here without a leading hash because it no longer resolves — has since
701+
* been deleted from the board. A verb family lives whole on
702+
* the contract: the vocabulary is already published on `MetadataLoader`
703+
* (which declares the same optional member over its own loader-local
704+
* options type); what this adds is the member's place HERE. `options` is
705+
* the manager's load-options bag, engine-local in shape — declared
706+
* `Record<string, unknown>` like {@link loadMany}'s. Optional like its
707+
* siblings, so a plane that predates it type-checks and is simply read as
708+
* "no keyed read here", `typeof … === 'function'` being the probe.
709+
*/
710+
loadManyKeyed?<T = unknown>(
711+
type: string,
712+
options?: Record<string, unknown>,
713+
): Promise<Array<{ name: string; data: T }>>;
714+
677715
// ==========================================
678716
// Import / Export
679717
// ==========================================

0 commit comments

Comments
 (0)