Skip to content

Commit c17719d

Browse files
feat(rest)!: serve ?names= on the /meta/:type list and close its query set (#30709)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D2RYndRGdbygg7ejR56Dk2B)_ domain:server#3 · os-elon-musk · session_016WV4cyaiN5dg5cw1zSyihP Fixes #29236 Clause-②: yes (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:`/meta/:type` 列表接口新增 `?names=a,b,c`,一次请求按名字读多个元数据项(`package` 列表除外,它的条目按 id 而非名字区分,带 `?names=` 会返回 400);同时列表只认七个查询参数,拼错或不认识的参数改为返回 400,不再静默返回整张表。 - 为什么改:裁决 A(6580261740)的服务端一半。控制台 Home 的 roster 门现在每个对象发一次请求(app-crm 上 8 次),有了批量读就能并成一次;拼错的过滤参数以前看起来像“匹配了全部”,现在会被明确拒绝。 - 风险与回滚:收窄是唯一的破坏性变化:带未声明参数的脚本会收到 400(控制台和 client SDK 只发已声明参数)。名字过滤在权限门之后、对象字段掩码之前执行,不改变谁能看到什么。回滚即 revert。 - 先看:`packages/rest/src/meta-item-read-gate.ts` 的 `META_LIST_QUERY_PARAMS`、`metaListNames`、列表链第 2a 步;`packages/runtime/src/domains/meta.ts` 的分发器对等实现。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/meta-list-names-filter.test.ts src/meta-query-arity-doors.test.ts`,预期全部通过。 - 需要你做的:在验收时确认 `locale` 加入封闭集合(裁决原文是六个键,见下方偏差 1);其余无,席位验收、契约复核通过、CI 全绿后落地。 ## What changes **`@objectstack/rest`, the list door `GET /api/v1/meta/:type` (and its environment-scoped twin).** - `?names=a,b,c` narrows every type's list to the items of those names, except `package`'s: a `package` list item is keyed by its id and carries no `name`, so the `package` list refuses `?names=` with a located `400 VALIDATION_ERROR` (`NAMES_ON_PACKAGE_LIST_MESSAGE`) on both transports, before any read. One comma-separated value, as the data routes spell `fields`; each name is trimmed, blanks are dropped and repeats count once (`metaListNames`). A name that matches no item is simply absent (no `404`). A value that names no item (`?names=`, `?names=,`) is refused `400 VALIDATION_ERROR` with `param: 'names'`, before the store read. A repeated `?names=` is refused like the list's other single-valued keys (`META_QUERY_ARITY_DOORS.list` gains `names`). - The narrowing is chain step 2a of `createMetaListAnswer`: after the per-caller gate (a named item the caller may not see stays withheld) and before the object field mask and translation, which now run on the named items only. - The names are pushed down to the store read as `GetMetaItemsRequest.names`. The chain step still narrows, so a store that does not filter answers the same list. - The list declares its closed query set, `META_LIST_QUERY_PARAMS` = `package`, `preview`, `object`, `include`, `id`, `names`, `locale`, judged by the same `unknownQueryParamRefusalMessage` that `refuseUnknownQueryParams` uses, before the arity check and before any read (`metaListUnknownParamRefusal` / `metaListQueryRefusal`). Bracketed keys that already have a located refusal keep it: `package[` everywhere, `id[` on `app`, `object[` on `view`. Any other bracketed key (`?names[]=`) is refused as a name the list does not read. - Three new exports, the decision both transports ask: `META_LIST_QUERY_PARAMS`, `metaListQueryRefusal`, `metaListNames`. **`@objectstack/runtime`, the dispatcher twin** (`packages/runtime/src/domains/meta.ts`): the same recognition and arity refusal, the same empty-value `400`, and the same push-down to `protocol.getMetaItems`, all asking the rest exports. The parity census (`meta-list-projection-parity.test.ts`) derives the read keys from both sources and now also pins that `META_LIST_QUERY_PARAMS` equals what the handlers actually read. **`@objectstack/metadata-protocol`**: `getMetaItems` / `readFlattenedMetaItems` take `names?: readonly string[]` and keep only those items once every source is merged, before the app nav contributions, locks, governance and serving decorations run. **`@objectstack/client`**: both `meta.getItems` methods (unscoped and environment-scoped) take `{ names }` and send it as one `?names=a,b` value. **`@objectstack/spec`, description only**: `GetMetaItemsRequestSchema.names` drops its `[PLANNED — not yet served] ` prefix and its trailing "no list door reads it yet" sentence; the marker test is deleted. `content/docs/references/api/protocol.mdx` is regenerated (`gen:docs`). **Docs**: one paragraph in `content/docs/protocol/kernel/http-protocol.mdx` › List All Objects on `?names=` and the closed set. **BREAKING** accept-set narrowing at the metadata list (`@objectstack/rest`, `@objectstack/runtime`). Before: `GET /meta/object?name=account`, `?ids=a,b` or `?filter=x` answered `200` with every object. Now: a name outside the seven is refused `400` with the nested `{ "error": { "code": "VALIDATION_ERROR", "message" } }` body naming the parameter and the supported ones, and nothing is read. Who moves: a script that sends an undeclared name; the Console and the client SDK send only declared names. Remedy: drop the parameter, or use `?names=a,b`, `?id=APP`, `?object=OBJECT` or `?package=ID`. The changeset carries the same section and an `adr-0087: not-required (no-migration-prescription)` disposition: no schema key moves, and nothing at rest records the route's answer. ## Why Ruling A on #29236 (6580261740): one spelling, `?names=a,b,c`, on every `/meta/:type` list, narrowing after the per-caller gate and before the object mask, pushed down as `GetMetaItemsRequest.names`, with the list's closed query set declared in the same server PR. The spec half (#29441, PR #29722) put `names` on `GetMetaItemsRequestSchema` with the planned marker; this PR serves it and retires the marker. Measured on `main` before this PR (6576480506): `?names=`, `?name=`, `?ids=`, `?filter=` on `/meta/object` each answered the full 115-item, 746 KB list with `200`. The Console follow-up, folding the Home roster gate's by-name reads into one `?names=` request, is its own card, #29444 (`domain:ui`). This PR does not touch `ui/**`. ## Deviations (named, accepted by the seat) 1. **`locale` joins the closed set.** The ruling's line reads: "The same server PR declares the list door's closed set of query parameters (`package, preview, object, include, id, names`)". The list door already reads `?locale=` (`extractLocale` in the translation step, a frame below the handler), so a six-key set would refuse `?locale=zh-CN`, which answers `200` today. Keeping it is keeping behaviour, not widening. Pinned: `?locale=zh-CN` still answers `200` on both transports, and the census derives `locale` from the source. The seat puts this to the maintainer at ACCEPT as a veto point. 2. **Patch levels, not minor.** The ruling sketched rest / metadata-protocol / client minor; `check-changeset-no-major` requires every entry to be `patch` when the PR declares `Metadata-protocol: none`, which this is (an API key, not authorable metadata). 3. **The docs paragraph** in `http-protocol.mdx` is outside the ruling's file list; the seat added it to the claim. 4. **Two existing controls flipped.** In `meta-app-publish-gate.test.ts`, a bracketed `id[]` on the `view` list and `object[]` on the `app` list used to answer `200` (ignored); they are now recognition refusals (`400`), as the closed set requires. 5. **`?names=` is refused on the `package` list** (contract review 6672872746, seat ruling): `package` items carry `manifest.id`, no `name`, so a names filter there could only answer `200 []`. It is a located `400` instead; matching packages by id is a different behaviour, left for its own card if someone needs it. The describe and `http-protocol.mdx` say "every type except `package`". ## Verification Main's behaviour is the card's measurement (6576480506) and the ablations below: with the recognition, the chain step and the push-down off, the new pins go red the way main answers (the whole list, `200`, no `names` sent to the store). - `packages/rest/src/meta-list-names-filter.test.ts` (new): the reading of `?names=`; the chain step on `object` (the mask and the translation see only the named items), an unknown name absent, an empty value rejected before the mask, `app` narrowed after the per-caller gate (a withheld app stays withheld); the door: push-down, a non-filtering store on `object` and `view` (and with `?object=`), no `names` sent when absent, empty `400` with no store read, unknown name `200` with an empty list. - `packages/rest/src/meta-query-arity-doors.test.ts`: the closed set, recognition before arity, every declared key accepted, undeclared and bracketed keys refused, `?locale=zh-CN` `200`. - `packages/metadata-protocol/src/protocol.meta-list-names.test.ts` (new, 5): narrowing on `object` and `app`, nav contributions only for the named apps, unknown names absent, each named item served as the whole list serves it. - `packages/runtime/src/domains/dispatcher-query-arity.test.ts` and `meta-list-projection-parity.test.ts`: the dispatcher's recognition, push-down, empty refusal and `locale`, and REST/dispatcher parity on five new probes (`names` narrowing, `names=nope`, empty `names`, `names[]`, `?name=crm`). - `packages/client/src/client.test.ts`: the `?names=` URL, unscoped and environment-scoped. - The `package` refusal: `meta-query-arity-doors.test.ts` (the sentence, every value spelling, recognition outranking it, controls on four other types, the door answering the nested `400` with no protocol read) and `dispatcher-query-arity.test.ts` (the dispatcher's twin). Ablated (the check off): 2 red in rest, 1 red in runtime. - Ablations, each red: rest recognition off (12), `locale` dropped from the set (3), chain step off (5), chain step moved after the mask (3), rest empty refusal off (5), rest push-down off (1); dispatcher recognition off (census red), dispatcher push-down off, dispatcher empty refusal off, protocol filter off (4), client `names` off. All green at head. - Gates (`dispatch-gates --commands`), first measured at `c2b596eb` before the main merge: 106 derived families plus the extras and `pnpm lint`, 112 run, 109 exit 0. `@objectstack/spec` `check:generated` failed only because `check:test-typecheck` ran out of heap; with an 8 GB heap it is green at `b0872d29` (all 14 artifacts current). `check:skill-examples` and `check:dual-build-cjs-loads` exited 3 (NOT MEASURED: they need a whole-tree build). The full re-run at the merged head, after a whole-tree build, goes in the os-dev report on the card. Skills-docs: follow-up #30710 ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [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: its generated artifacts are regenerated and committed. - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyaiN5dg5cw1zSyihP <!-- signed: domain:server#3 · os-elon-musk · session_016WV4cyaiN5dg5cw1zSyihP --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 974209d commit c17719d

19 files changed

Lines changed: 850 additions & 49 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
'@objectstack/client': patch
6+
'@objectstack/spec': patch
7+
---
8+
9+
feat(rest)!: the metadata list reads several items by name in one request (`?names=a,b,c`), and refuses a query parameter it does not read
10+
11+
Clause-②: yes (narrowing) — `GET /api/v1/meta/:type` (and its environment-scoped twin, on both the REST server and the runtime dispatcher) declares its closed query-parameter set, `package`, `preview`, `object`, `include`, `id`, `names` and `locale`, and refuses any other name with `400 VALIDATION_ERROR`, where it ignored it and answered the whole list with `200`. The widening half: `?names=a,b,c` narrows every type's list but `package`'s to the items of those names, `@objectstack/client`'s `meta.getItems(type, { names })` sends it, and `@objectstack/rest` exports three new names, the list's query decision that both transports ask: `META_LIST_QUERY_PARAMS`, `metaListQueryRefusal` and `metaListNames`. Ruling 6580261740 assigns both halves to this one server change.
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: `GetMetaItemsRequestSchema.names` was already declared, and only its description loses the planned marker. What moves is the list route's answer to a query-string name it never read, and nothing at rest records that answer, so there is nothing for a migration to rewrite. -->
15+
16+
**The batch by-names read.** `GET /api/v1/meta/:type?names=a,b,c` answers only the items of those names, among the items the list would answer without it, on every metadata type except `package`, whose items are keyed by id and carry no `name`: the `package` list refuses `?names=` with `400 VALIDATION_ERROR`. One comma-separated value, as the data routes spell `fields`; each name is trimmed and repeats count once. A name that matches no item is simply absent from the answer (no `404`). The names are handed to the store read (`GetMetaItemsRequest.names`), so `@objectstack/metadata-protocol`'s `getMetaItems` filters before it computes locks, governance and diagnostics, and the list's per-caller gate, object field mask and translation run on the named items only. A value that names no item (`?names=`, `?names=,`) is refused with `400 VALIDATION_ERROR` rather than read as every item; leave the key out to list every item. A repeated `?names=` is refused like the list's other single-valued parameters. Before, `?names=` was ignored, so a client that needed several objects paid one by-name request for each.
17+
18+
**BREAKING** accept-set narrowing at the metadata list (`@objectstack/rest`, `@objectstack/runtime`): a query parameter the list does not read is refused instead of ignored.
19+
20+
- **Before.** `GET /meta/object?name=account`, `?ids=a,b` or `?filter=x` answered `200` with every object, which reads the same as a filter that matched everything.
21+
- **Now.** A name outside `package`, `preview`, `object`, `include`, `id`, `names`, `locale` is refused with `400` and the nested `{ "error": { "code": "VALIDATION_ERROR", "message" } }` body; the message names the parameter and lists the supported ones, and nothing is read. A bracketed `?package[]=`, and `?id[]=` on the app list or `?object[]=` on the view list, keep their own located refusal; any other bracketed key (`?names[]=`) is refused as a name the list does not read.
22+
- **Who moves.** The Console and the client SDK send only declared names. A script that sends another name gets the 400.
23+
24+
**The remedy.** Drop the parameter, or spell it as one of the supported names: `?names=a,b` for several items by name, `?id=APP` for one app, `?object=OBJECT` for one object's views, `?package=ID` for one package.

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,17 @@ A type listing returns `{ type, items }` — the requested metadata `type` plus
967967
}
968968
```
969969

970+
To read several items of one type in a single request, name them:
971+
`GET /api/v1/meta/object?names=account,contact` answers only those items, among
972+
the ones the caller may see. The value is one comma-separated list; a name that
973+
matches no item is simply absent from `items`, and an empty value (`?names=`) is
974+
refused with `400 VALIDATION_ERROR`. `?names=` works on every type except
975+
`package`, whose items are keyed by id rather than name; the `package` list
976+
refuses it with `400 VALIDATION_ERROR`. The list reads a closed set of query
977+
parameters (`package`, `preview`, `object`, `include`, `id`, `names` and
978+
`locale`) and refuses any other name with `400 VALIDATION_ERROR` instead of
979+
ignoring it.
980+
970981
### Get Object Schema
971982

972983
**Request:**

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1517,7 +1517,7 @@ One changed key path of the pending draft, and the last write that changed it.
15171517
| :--- | :--- | :--- | :--- |
15181518
| **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") |
15191519
| **packageId** | `string` | optional | Optional package ID to filter items by |
1520-
| **names** | `string[]` | optional | [PLANNED — not yet served] Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. No list door reads it yet: until one does, a request carrying it is answered as if it were absent. |
1520+
| **names** | `string[]` | optional | Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Served on every type except `package`, whose items are keyed by id; the `package` list refuses it with 400. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. |
15211521
| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. |
15221522

15231523

‎packages/client/src/client.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,28 @@ describe('ObjectStackClient', () => {
187187
expect(result.items).toHaveLength(2);
188188
});
189189

190+
it('[#29236] sends `names` as ONE comma-separated `?names=` value, on the unscoped and the environment surface', async () => {
191+
const fetchMock = vi.fn().mockResolvedValue({
192+
ok: true,
193+
json: async () => ({ type: 'object', items: [{ name: 'customer' }] }),
194+
});
195+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
196+
197+
await client.meta.getItems('object', { names: ['customer', 'order'] });
198+
await client.meta.getItems('object', { packageId: 'com.acme', names: ['customer'] });
199+
await client.environment('env_1').meta.getItems('object', { names: ['customer', 'order'] });
200+
// An empty list is sent as the empty filter the server refuses, never dropped into "every item".
201+
await client.meta.getItems('object', { names: [] });
202+
203+
const urls = fetchMock.mock.calls.map((c) => String(c[0]));
204+
expect(urls).toEqual([
205+
'http://localhost:3000/api/v1/meta/object?names=customer%2Corder',
206+
'http://localhost:3000/api/v1/meta/object?package=com.acme&names=customer',
207+
'http://localhost:3000/api/v1/environments/env_1/meta/object?names=customer%2Corder',
208+
'http://localhost:3000/api/v1/meta/object?names=',
209+
]);
210+
});
211+
190212
it('should get metadata item by type and name', async () => {
191213
// The server's body is the spec-declared `GetMetaItemResponseSchema`
192214
// envelope — `{ type, name, item }`, with the metadata document under

‎packages/client/src/index.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,12 +1939,17 @@ export class ObjectStackClient {
19391939
/**
19401940
* Get all items of a specific metadata type
19411941
* @param type - Metadata type name (e.g., 'object', 'plugin')
1942-
* @param options - Optional filters (e.g., packageId to scope by package)
1942+
* @param options - Optional filters: `packageId` scopes the list to one
1943+
* package; `names` narrows it to the items of those names, in ONE request
1944+
* (sent as `?names=a,b,c`). A name that matches no item is simply absent
1945+
* from the answer. An empty `names` array is sent as an empty filter,
1946+
* which the server refuses with `400`; leave `names` out to list every item.
19431947
*/
1944-
getItems: async (type: string, options?: { packageId?: string }): Promise<GetMetaItemsResponse> => {
1948+
getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise<GetMetaItemsResponse> => {
19451949
const route = this.getRoute('metadata');
19461950
const params = new URLSearchParams();
19471951
if (options?.packageId) params.set('package', options.packageId);
1952+
if (options?.names !== undefined) params.set('names', options.names.join(','));
19481953
const qs = params.toString();
19491954
const url = `${this.baseUrl}${route}/${type}${qs ? `?${qs}` : ''}`;
19501955
const res = await this.fetch(url);
@@ -8194,9 +8199,11 @@ export class ScopedEnvironmentClient {
81948199
const res = await this.parent._fetch(this.metaUrl(''));
81958200
return this.parent._unwrap<GetMetaTypesResponse>(res);
81968201
},
8197-
getItems: async (type: string, options?: { packageId?: string }): Promise<GetMetaItemsResponse> => {
8202+
/** The unscoped surface's `getItems`, `names` included (one `?names=a,b,c` request). */
8203+
getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise<GetMetaItemsResponse> => {
81988204
const params = new URLSearchParams();
81998205
if (options?.packageId) params.set('package', options.packageId);
8206+
if (options?.names !== undefined) params.set('names', options.names.join(','));
82008207
const qs = params.toString();
82018208
const res = await this.parent._fetch(this.metaUrl(`/${type}${qs ? `?${qs}` : ''}`));
82028209
return this.parent._unwrap<GetMetaItemsResponse>(res);

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { viewIdentityPatch } from './protocol-view-normalization.js';
5151
import { isServedOverrideRowOf } from './served-override.js';
5252

5353
/** The body of `ObjectStackProtocolImplementation#getMetaItems`; the method's docblock there is its contract. */
54-
export async function getMetaItems(host: Pick<ProtocolHost, 'readFlattenedMetaItems'>, request: { type: string; packageId?: string; previewDrafts?: boolean }) {
54+
export async function getMetaItems(host: Pick<ProtocolHost, 'readFlattenedMetaItems'>, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) {
5555
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one
5656
// is load-bearing twice over: the SchemaRegistry indexes code-authored
5757
// items under the SINGULAR type, and the overlay-hydration branch below
@@ -78,7 +78,7 @@ export async function getMetaItemsPerPackage(host: Pick<ProtocolHost, 'readFlatt
7878
}
7979

8080
/** The body of `ObjectStackProtocolImplementation#readFlattenedMetaItems`; the method's docblock there is its contract. */
81-
export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactLockLayerAt' | 'baseServesBundleMember' | 'convertStoredItem' | 'declinesStoredRow' | 'disabledActionsOf' | 'engine' | 'environmentId' | 'expandStoredViewContainers' | 'findEnvironmentRows' | 'flowRegistrationsOf' | 'foldObjectExtendersFromRegistry' | 'getServicesRegistry' | 'governServedObject' | 'hydrateOverlayIntoRegistry' | 'isStoredEntryOfDeclinedName' | 'isUnservedSealedOverlay' | 'isWritablePackage' | 'lookupArtifactItem' | 'namesWithOwnStoredRow' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'shippedArtifactsOf' | 'shippingPackagesOf' | 'shipsBundleName' | 'storedOverlayEntries'>, request: { type: string; packageId?: string; previewDrafts?: boolean },
81+
export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactLockLayerAt' | 'baseServesBundleMember' | 'convertStoredItem' | 'declinesStoredRow' | 'disabledActionsOf' | 'engine' | 'environmentId' | 'expandStoredViewContainers' | 'findEnvironmentRows' | 'flowRegistrationsOf' | 'foldObjectExtendersFromRegistry' | 'getServicesRegistry' | 'governServedObject' | 'hydrateOverlayIntoRegistry' | 'isStoredEntryOfDeclinedName' | 'isUnservedSealedOverlay' | 'isWritablePackage' | 'lookupArtifactItem' | 'namesWithOwnStoredRow' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'shippedArtifactsOf' | 'shippingPackagesOf' | 'shipsBundleName' | 'storedOverlayEntries'>, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean },
8282
audience: 'served' | 'execution',
8383
layer: 'effective' | 'per-package' = 'effective',) {
8484
const { packageId } = request;
@@ -690,6 +690,21 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
690690
items = (items as any[]).filter((it) => !(it && typeof it === 'object' && unseated.has(it)));
691691
}
692692

693+
// [#29236, ruling 6580261740 letter A] `GetMetaItemsRequest.names` — the
694+
// list door's `?names=a,b`, pushed down: only the items of those names,
695+
// kept HERE, once every source is merged and before any step below, so the
696+
// narrowing, the hiding, the lock selection, the governance and the
697+
// serving decorations all run on the named items alone. Every step below
698+
// judges an item by its own name and rows, so the named items are served
699+
// exactly as the whole list would serve them. A name that matches nothing
700+
// is simply absent. Absent, every item (the list as before); the door
701+
// refuses an empty filter before it reads, so an empty one never names
702+
// "every item" here either: it keeps none.
703+
if (request.names !== undefined) {
704+
const wanted = new Set(request.names);
705+
items = (items as any[]).filter((it) => it && typeof it === 'object' && wanted.has((it as any).name));
706+
}
707+
693708
// [#28409, ADR-0146 D8 items 1–2, #28238's list half] THE ENV-WIDE
694709
// LIST JOINS THE ONE EFFECTIVE READ. Naming no package, a name exactly
695710
// one code package ships is listed once, as that package's item
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29236, ruling 6580261740 letter A] `getMetaItems({ type, names })` — the
5+
* list door's `?names=a,b` pushed down to the store read
6+
* (`GetMetaItemsRequestSchema.names`).
7+
*
8+
* The protocol keeps the items of those names once every source is merged,
9+
* BEFORE the per-item work after the merge (the app nav contributions, the lock
10+
* selection, the governance, the serving decorations), so that work runs on the
11+
* named items alone. A name that matches nothing is simply absent.
12+
*
13+
* The engine is the shape `get-meta-items-package-writable.test.ts` drives: the
14+
* registry surface the list read touches and a `find` that answers the overlay
15+
* query with nothing.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { ObjectStackProtocolImplementation } from './protocol.js';
20+
21+
const APPS = ['crm', 'helpdesk', 'payroll'].map((name) => ({ name, label: name.toUpperCase(), navigation: [] }));
22+
const OBJECTS = ['account', 'contact', 'lead'].map((name) => ({ name, label: name, fields: { name: { type: 'text' } } }));
23+
24+
function make() {
25+
const contributedTo: string[] = [];
26+
const engine: any = {
27+
manifests: new Map(),
28+
find: async () => [],
29+
registry: {
30+
listItems: (type: string) => structuredClone(type === 'app' ? APPS : type === 'object' ? OBJECTS : []),
31+
getPackage: () => undefined,
32+
getItem: () => undefined,
33+
getObject: () => undefined,
34+
getArtifactItem: () => undefined,
35+
isPackageDisabled: () => false,
36+
applyNavContributions: (app: any) => {
37+
contributedTo.push(app?.name);
38+
return app;
39+
},
40+
},
41+
};
42+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
43+
return { protocol, contributedTo };
44+
}
45+
46+
const namesOf = (res: any): string[] => (res.items as any[]).map((it) => it.name);
47+
48+
describe('getMetaItems — `names` narrows the served list (#29236)', () => {
49+
it('keeps only the named items, on `object` and on `app`', async () => {
50+
const { protocol } = make();
51+
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['lead', 'account'] }))).toEqual(['account', 'lead']);
52+
expect(namesOf(await protocol.getMetaItems({ type: 'app', names: ['helpdesk'] }))).toEqual(['helpdesk']);
53+
});
54+
55+
it('narrows BEFORE the per-item work after the merge: only the named apps get their nav contributions', async () => {
56+
const { protocol, contributedTo } = make();
57+
await protocol.getMetaItems({ type: 'app', names: ['payroll', 'crm'] });
58+
expect(contributedTo.sort()).toEqual(['crm', 'payroll']);
59+
});
60+
61+
it('a name that matches nothing is simply absent', async () => {
62+
const { protocol } = make();
63+
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['account', 'nope'] }))).toEqual(['account']);
64+
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['nope'] }))).toEqual([]);
65+
});
66+
67+
it('serves each named item exactly as the whole list serves it', async () => {
68+
const { protocol } = make();
69+
const whole = await protocol.getMetaItems({ type: 'object' });
70+
const named = await protocol.getMetaItems({ type: 'object', names: ['contact'] });
71+
expect(named.items).toEqual((whole.items as any[]).filter((it) => it.name === 'contact'));
72+
});
73+
74+
it('control: absent `names` lists every item, and every app gets its contributions', async () => {
75+
const { protocol, contributedTo } = make();
76+
expect(namesOf(await protocol.getMetaItems({ type: 'object' }))).toEqual(['account', 'contact', 'lead']);
77+
await protocol.getMetaItems({ type: 'app' });
78+
expect(contributedTo.sort()).toEqual(['crm', 'helpdesk', 'payroll']);
79+
});
80+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3814,7 +3814,13 @@ export class ObjectStackProtocolImplementation implements
38143814
return throwMetadataServiceUnavailable(errors);
38153815
}
38163816

3817-
async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean }) {
3817+
/**
3818+
* The served list of one metadata type. [#29236] `names` narrows it to the
3819+
* items of those names (`GetMetaItemsRequestSchema.names`): a name that
3820+
* matches nothing is absent, and the narrowing runs before the serving
3821+
* decorations, so they are computed for the named items only.
3822+
*/
3823+
async getMetaItems(request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) {
38183824
// [#25467 E1] The body moved to `protocol-meta-list-reads.ts`; it reaches this class through `protocol-host.ts`.
38193825
return getMetaItems(protocolHost(this), request);
38203826
}
@@ -3988,7 +3994,7 @@ export class ObjectStackProtocolImplementation implements
39883994
* `request` arrives already folded by the calling face (#4432).
39893995
*/
39903996
private async readFlattenedMetaItems(
3991-
request: { type: string; packageId?: string; previewDrafts?: boolean },
3997+
request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean },
39923998
audience: 'served' | 'execution',
39933999
layer: 'effective' | 'per-package' = 'effective',
39944000
) {

0 commit comments

Comments
 (0)