Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/15385-metadata-service-load-many-keyed.md
Original file line number Diff line number Diff line change
@@ -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?<T = unknown>(
type: string,
options?: Record<string, unknown>,
): Promise<Array<{ name: string; data: T }>>;
```

**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.
50 changes: 50 additions & 0 deletions content/docs/kernel/contracts/metadata-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface IMetadataService {
load?<T>(type: string, name: string, options?): Promise<T | null>;
loadDiagnosed?<T>(type: string, name: string, options?):
Promise<{ data: T | null; degraded: boolean; errors: string[] }>;
loadManyKeyed?<T>(type: string, options?): Promise<Array<{ name: string; data: T }>>;

// Convenience accessors for UI metadata (optional)
getView?(name: string): Promise<unknown | undefined>;
Expand Down Expand Up @@ -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.

<Callout type="warn">
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.
</Callout>

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.
}
```

<Callout type="info">
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.
</Callout>

### list / listNames

The plural reads' failure posture. Both read a **set** through the same
Expand Down
30 changes: 3 additions & 27 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?<T = unknown>(
type: string,
options?: Record<string, unknown>,
): Promise<Array<{ name: string; data: T }>>;
};

/** Type guard — checks whether the service exposes `loadMetaFromDb`. */
function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
return (
Expand Down Expand Up @@ -2555,14 +2534,11 @@ export class ObjectQLPlugin implements Plugin {
*/
private async resolveGovernanceMetadataService(
ctx: PluginContext,
): Promise<(IMetadataService & KeyedPluralMetadataRead) | undefined> {
): Promise<IMetadataService | undefined> {
const scopeId = this.environmentId;
if (scopeId && typeof ctx.getServiceScoped === 'function') {
try {
const scoped = await ctx.getServiceScoped<IMetadataService & KeyedPluralMetadataRead>(
'metadata',
scopeId,
);
const scoped = await ctx.getServiceScoped<IMetadataService>('metadata', scopeId);
if (scoped != null) return scoped;
} catch {
// Not resolvable under a scope on this host — nothing registered under
Expand All @@ -2573,7 +2549,7 @@ export class ObjectQLPlugin implements Plugin {
}
}
try {
return ctx.getService<IMetadataService & KeyedPluralMetadataRead>('metadata');
return ctx.getService<IMetadataService>('metadata');
} catch {
return undefined;
}
Expand Down
108 changes: 108 additions & 0 deletions packages/spec/src/contracts/metadata-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T = unknown>(type: string) =>
type === 'customization' ? [{ name: 'account', data: namelessBody as T }] : [],
};

const keyed = await service.loadManyKeyed!<typeof namelessBody>('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 <T = unknown>() => [{ name: 'account', data: namelessBody as T }],
};

const keyed = await service.loadManyKeyed!<typeof namelessBody>('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 <T = unknown>() => [{ name: 'account', data: namelessBody as T }],
};

// Type-level shape assertion: these annotations only compile against the
// declared `Promise<Array<{ name: string; data: T }>>`.
const keyed = await service.loadManyKeyed!<Overrides>('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<string, unknown> | undefined;
const service: IMetadataService = {
...baseService(),
loadManyKeyed: async (_type: string, options?: Record<string, unknown>) => {
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();
});
});
});
38 changes: 38 additions & 0 deletions packages/spec/src/contracts/metadata-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,44 @@ export interface IMetadataService {
*/
loadMany?<T = unknown>(type: string, options?: Record<string, unknown>): Promise<T[]>;

/**
* 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<string, unknown>` 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?<T = unknown>(
type: string,
options?: Record<string, unknown>,
): Promise<Array<{ name: string; data: T }>>;

// ==========================================
// Import / Export
// ==========================================
Expand Down
Loading