diff --git a/.changeset/15385-metadata-service-load-many-keyed.md b/.changeset/15385-metadata-service-load-many-keyed.md new file mode 100644 index 00000000000..2b4dd087585 --- /dev/null +++ b/.changeset/15385-metadata-service-load-many-keyed.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +`IMetadataService` declares `loadManyKeyed?` — the keyed plural loader read now sits on the contract beside its two declared siblings `loadMany?` and `loadDiagnosed?` (#15385). + +Clause-②: yes + +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. + +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`. + +```ts +loadManyKeyed?( + type: string, + options?: Record, +): Promise>; +``` + +**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. + +**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. + +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: 「同意」. + +`content/docs/kernel/contracts/metadata-service.mdx` gains the member in the same change. diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 28444193126..86674fffe56 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -51,6 +51,7 @@ export interface IMetadataService { load?(type: string, name: string, options?): Promise; loadDiagnosed?(type: string, name: string, options?): Promise<{ data: T | null; degraded: boolean; errors: string[] }>; + loadManyKeyed?(type: string, options?): Promise>; // Convenience accessors for UI metadata (optional) getView?(name: string): Promise; @@ -140,6 +141,55 @@ if (degraded) { } ``` +### loadManyKeyed + +Optional. Reads **every** item of a type through the registered loaders, each +one paired with the key its loader holds it under. + +The key is a fact about the **store**, not about the body — it is `register`'s +own `name` argument — and it travels *beside* `data`, never inside it. `data` is +exactly what the unkeyed `loadMany` would return for the same item, so no +consumer ever sees a synthesised `name`. + +That difference is the point. A stored body with no top-level `name` is legal +and deliberate — an aggregated `defineView` container has none **by design**, +because its 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. Reach for the keyed read wherever the set you are assembling is +addressed by key. + + +Not to be confused with the **org customization overlay**, which is ADR-0005's +`sys_metadata` mechanism described under "Overlay Management" below — a +different thing entirely. + + +Probe before you call, and treat an absent member as *absent*, never as an empty +result — this is the same distinction `loadDiagnosed` exists for, one member +over: + +```typescript +const keyedRead = metadataService.loadManyKeyed; + +if (typeof keyedRead === 'function') { + for (const { name, data } of await keyedRead.call(metadataService, 'action')) { + // `name` is the key the store holds this item under — present even when + // `data` carries none of its own. + } +} else { + // This plane offers no keyed read. ⛔ Do NOT fall through to an empty set: + // "no keys available here" and "the store holds nothing" are different + // facts. Fall back to the unkeyed plural read, or record that keys could + // not be obtained. +} +``` + + +Probe it like every optional member — +`typeof metadataService.loadManyKeyed === 'function'`. A metadata plane that +does not offer it is read as "no keyed read here", never as an empty set. + + ### list / listNames The plural reads' failure posture. Both read a **set** through the same diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 394e45e06f2..98afc50a9ca 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -55,27 +55,6 @@ interface ProtocolWithDbRestore { }>; } -/** - * [#14423] The keyed plural read the governance audit reads the metadata plane - * through — `MetadataManager.loadManyKeyed(type)`, structurally. - * - * Declared HERE, beside the one call site, rather than on `IMetadataService`: - * `packages/spec` is a contract surface owned by another lane, and widening it - * is its own decision with its own review. This is the same position - * `loadDiagnosed` was in before it was declared — the call site and the - * implementation agreed, and the contract was what nobody had written — and - * the same remedy applies when that lane takes it: delete this and read the - * contract. ⛔ Not `any`: intersecting the slot's real contract keeps the - * lookup typed (#4251), and the member stays optional so a plane that predates - * it type-checks and is simply read as "no keyed read here". - */ -type KeyedPluralMetadataRead = { - loadManyKeyed?( - type: string, - options?: Record, - ): Promise>; -}; - /** Type guard — checks whether the service exposes `loadMetaFromDb`. */ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore { return ( @@ -2555,14 +2534,11 @@ export class ObjectQLPlugin implements Plugin { */ private async resolveGovernanceMetadataService( ctx: PluginContext, - ): Promise<(IMetadataService & KeyedPluralMetadataRead) | undefined> { + ): Promise { const scopeId = this.environmentId; if (scopeId && typeof ctx.getServiceScoped === 'function') { try { - const scoped = await ctx.getServiceScoped( - 'metadata', - scopeId, - ); + const scoped = await ctx.getServiceScoped('metadata', scopeId); if (scoped != null) return scoped; } catch { // Not resolvable under a scope on this host — nothing registered under @@ -2573,7 +2549,7 @@ export class ObjectQLPlugin implements Plugin { } } try { - return ctx.getService('metadata'); + return ctx.getService('metadata'); } catch { return undefined; } diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts index 42f4403faa7..3a327c377d9 100644 --- a/packages/spec/src/contracts/metadata-service.test.ts +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -514,4 +514,112 @@ describe('Metadata Service Contract', () => { expect(params).toEqual({}); }); }); + + // ========================================== + // Keyed plural loader read (#15385 batch #123 item 5) + // ========================================== + + describe('loadManyKeyed (optional member)', () => { + /** A minimal base implementation with only the REQUIRED members. */ + const baseService = (): IMetadataService => ({ + register: async () => {}, + get: async () => undefined, + list: async () => [], + unregister: async () => {}, + exists: async () => false, + listNames: async () => [], + getObject: async () => undefined, + listObjects: async () => [], + }); + + /** + * A stored body with NO top-level `name` — the shape the whole member + * exists for. Its identity is the key the store holds it under, so keying + * the unkeyed plural read by `data.name` has nothing to key it by. + */ + const namelessBody = { label: 'Account overrides', fields: [] as unknown[] }; + + it('is optional — an implementation without it still satisfies the contract', () => { + const service = baseService(); + + // The optional-member convention: consumers probe before they call. + expect(typeof service.loadManyKeyed).toBe('undefined'); + expect(typeof (service as IMetadataService).loadManyKeyed === 'function').toBe(false); + }); + + it('is probeable with typeof === "function" when provided', () => { + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [], + }; + + expect(typeof service.loadManyKeyed).toBe('function'); + }); + + it('answers (key, body) pairs, and an empty set for a type nothing holds', async () => { + const service: IMetadataService = { + ...baseService(), + // Generic, because the declaration is: a double holding one fixed body + // can only answer under the `T` its CALLER names. + loadManyKeyed: async (type: string) => + type === 'customization' ? [{ name: 'account', data: namelessBody as T }] : [], + }; + + const keyed = await service.loadManyKeyed!('customization'); + expect(keyed).toEqual([{ name: 'account', data: namelessBody }]); + + expect(await service.loadManyKeyed!('no_such_type')).toEqual([]); + }); + + it('carries the key BESIDE the body — nothing is folded into `data`', async () => { + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [{ name: 'account', data: namelessBody as T }], + }; + + const keyed = await service.loadManyKeyed!('customization'); + + // The body is the same object the unkeyed read would have returned... + expect(keyed[0]!.data).toBe(namelessBody); + // ...so the key lives only on the pair, never synthesised into the body. + expect('name' in keyed[0]!.data).toBe(false); + expect(keyed[0]!.name).toBe('account'); + }); + + it('types the key as string and the body as T', async () => { + type Overrides = { label: string; fields: unknown[] }; + + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [{ name: 'account', data: namelessBody as T }], + }; + + // Type-level shape assertion: these annotations only compile against the + // declared `Promise>`. + const keyed = await service.loadManyKeyed!('customization'); + const name: string = keyed[0]!.name; + const body: Overrides = keyed[0]!.data; + + expect(name).toBe('account'); + expect(body.label).toBe('Account overrides'); + }); + + it('takes the same engine-local options bag as its unkeyed sibling', async () => { + let seen: Record | undefined; + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async (_type: string, options?: Record) => { + seen = options; + return []; + }, + }; + + await service.loadManyKeyed!('customization', { patterns: ['*.json'] }); + expect(seen).toEqual({ patterns: ['*.json'] }); + + // `options` is optional — the one in-repo caller passes nothing. + await service.loadManyKeyed!('customization'); + expect(seen).toBeUndefined(); + }); + }); }); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index eddcf910298..f46d51bd859 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -674,6 +674,44 @@ export interface IMetadataService { */ loadMany?(type: string, options?: Record): Promise; + /** + * Load EVERY item of a type across all loaders, each paired with the KEY + * its loader holds it under — the keyed twin of {@link loadMany}. + * + * `name` is the STORE's key (`register()`'s `name` argument), carried + * BESIDE the body and never folded into it: `data` is exactly what + * {@link loadMany} would return for the same item, so no consumer ever sees + * a synthesised name and the register contract's `data.name` check keeps + * meaning what it means. That is the whole reason this is a second member + * rather than a widened {@link loadMany} return: an item whose stored body + * has no top-level `name` — an aggregated `defineView` container, which has + * none BY DESIGN because its identity is the object it targets — has no + * identity at all in the unkeyed read, and keying that read by `data.name` + * drops it. (⛔ Not the org customization overlay, which is ADR-0005's + * `sys_metadata` mechanism and a different thing entirely.) + * + * [#15385 batch #123 item 5] Declared alongside {@link loadMany} / + * {@link loadDiagnosed} — the position {@link loadDiagnosed} was in before + * #4127 batch 4, and resolved the same way. Implemented by + * `MetadataManager` in #15378 and reached by the ObjectQL governance audit + * through a local structural type beside its one call site, with the + * contract the only thing nobody had written. ⚠️ #15378 is the LIVE record + * for that landing: the card it was filed under — issue 14423, written + * here without a leading hash because it no longer resolves — has since + * been deleted from the board. A verb family lives whole on + * the contract: the vocabulary is already published on `MetadataLoader` + * (which declares the same optional member over its own loader-local + * options type); what this adds is the member's place HERE. `options` is + * the manager's load-options bag, engine-local in shape — declared + * `Record` like {@link loadMany}'s. Optional like its + * siblings, so a plane that predates it type-checks and is simply read as + * "no keyed read here", `typeof … === 'function'` being the probe. + */ + loadManyKeyed?( + type: string, + options?: Record, + ): Promise>; + // ========================================== // Import / Export // ==========================================