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
24 changes: 24 additions & 0 deletions .changeset/29236-meta-list-names-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/rest': patch
'@objectstack/runtime': patch
'@objectstack/metadata-protocol': patch
'@objectstack/client': patch
'@objectstack/spec': patch
---

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

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.
Metadata-protocol: none

<!-- 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. -->

**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.

**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.

- **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.
- **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.
- **Who moves.** The Console and the client SDK send only declared names. A script that sends another name gets the 400.

**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.
11 changes: 11 additions & 0 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,17 @@ A type listing returns `{ type, items }` — the requested metadata `type` plus
}
```

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

### Get Object Schema

**Request:**
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1517,7 +1517,7 @@ One changed key path of the pending draft, and the last write that changed it.
| :--- | :--- | :--- | :--- |
| **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") |
| **packageId** | `string` | optional | Optional package ID to filter items by |
| **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. |
| **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. |
| **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. |


Expand Down
22 changes: 22 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,28 @@ describe('ObjectStackClient', () => {
expect(result.items).toHaveLength(2);
});

it('[#29236] sends `names` as ONE comma-separated `?names=` value, on the unscoped and the environment surface', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ type: 'object', items: [{ name: 'customer' }] }),
});
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });

await client.meta.getItems('object', { names: ['customer', 'order'] });
await client.meta.getItems('object', { packageId: 'com.acme', names: ['customer'] });
await client.environment('env_1').meta.getItems('object', { names: ['customer', 'order'] });
// An empty list is sent as the empty filter the server refuses, never dropped into "every item".
await client.meta.getItems('object', { names: [] });

const urls = fetchMock.mock.calls.map((c) => String(c[0]));
expect(urls).toEqual([
'http://localhost:3000/api/v1/meta/object?names=customer%2Corder',
'http://localhost:3000/api/v1/meta/object?package=com.acme&names=customer',
'http://localhost:3000/api/v1/environments/env_1/meta/object?names=customer%2Corder',
'http://localhost:3000/api/v1/meta/object?names=',
]);
});

it('should get metadata item by type and name', async () => {
// The server's body is the spec-declared `GetMetaItemResponseSchema`
// envelope — `{ type, name, item }`, with the metadata document under
Expand Down
13 changes: 10 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1939,12 +1939,17 @@ export class ObjectStackClient {
/**
* Get all items of a specific metadata type
* @param type - Metadata type name (e.g., 'object', 'plugin')
* @param options - Optional filters (e.g., packageId to scope by package)
* @param options - Optional filters: `packageId` scopes the list to one
* package; `names` narrows it to the items of those names, in ONE request
* (sent as `?names=a,b,c`). A name that matches no item is simply absent
* from the answer. An empty `names` array is sent as an empty filter,
* which the server refuses with `400`; leave `names` out to list every item.
*/
getItems: async (type: string, options?: { packageId?: string }): Promise<GetMetaItemsResponse> => {
getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise<GetMetaItemsResponse> => {
const route = this.getRoute('metadata');
const params = new URLSearchParams();
if (options?.packageId) params.set('package', options.packageId);
if (options?.names !== undefined) params.set('names', options.names.join(','));
const qs = params.toString();
const url = `${this.baseUrl}${route}/${type}${qs ? `?${qs}` : ''}`;
const res = await this.fetch(url);
Expand Down Expand Up @@ -8194,9 +8199,11 @@ export class ScopedEnvironmentClient {
const res = await this.parent._fetch(this.metaUrl(''));
return this.parent._unwrap<GetMetaTypesResponse>(res);
},
getItems: async (type: string, options?: { packageId?: string }): Promise<GetMetaItemsResponse> => {
/** The unscoped surface's `getItems`, `names` included (one `?names=a,b,c` request). */
getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise<GetMetaItemsResponse> => {
const params = new URLSearchParams();
if (options?.packageId) params.set('package', options.packageId);
if (options?.names !== undefined) params.set('names', options.names.join(','));
const qs = params.toString();
const res = await this.parent._fetch(this.metaUrl(`/${type}${qs ? `?${qs}` : ''}`));
return this.parent._unwrap<GetMetaItemsResponse>(res);
Expand Down
19 changes: 17 additions & 2 deletions packages/metadata-protocol/src/protocol-meta-list-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { viewIdentityPatch } from './protocol-view-normalization.js';
import { isServedOverrideRowOf } from './served-override.js';

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

/** The body of `ObjectStackProtocolImplementation#readFlattenedMetaItems`; the method's docblock there is its contract. */
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 },
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 },
audience: 'served' | 'execution',
layer: 'effective' | 'per-package' = 'effective',) {
const { packageId } = request;
Expand Down Expand Up @@ -690,6 +690,21 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
items = (items as any[]).filter((it) => !(it && typeof it === 'object' && unseated.has(it)));
}

// [#29236, ruling 6580261740 letter A] `GetMetaItemsRequest.names` — the
// list door's `?names=a,b`, pushed down: only the items of those names,
// kept HERE, once every source is merged and before any step below, so the
// narrowing, the hiding, the lock selection, the governance and the
// serving decorations all run on the named items alone. Every step below
// judges an item by its own name and rows, so the named items are served
// exactly as the whole list would serve them. A name that matches nothing
// is simply absent. Absent, every item (the list as before); the door
// refuses an empty filter before it reads, so an empty one never names
// "every item" here either: it keeps none.
if (request.names !== undefined) {
const wanted = new Set(request.names);
items = (items as any[]).filter((it) => it && typeof it === 'object' && wanted.has((it as any).name));
}

// [#28409, ADR-0146 D8 items 1–2, #28238's list half] THE ENV-WIDE
// LIST JOINS THE ONE EFFECTIVE READ. Naming no package, a name exactly
// one code package ships is listed once, as that package's item
Expand Down
80 changes: 80 additions & 0 deletions packages/metadata-protocol/src/protocol.meta-list-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29236, ruling 6580261740 letter A] `getMetaItems({ type, names })` — the
* list door's `?names=a,b` pushed down to the store read
* (`GetMetaItemsRequestSchema.names`).
*
* The protocol keeps the items of those names once every source is merged,
* BEFORE the per-item work after the merge (the app nav contributions, the lock
* selection, the governance, the serving decorations), so that work runs on the
* named items alone. A name that matches nothing is simply absent.
*
* The engine is the shape `get-meta-items-package-writable.test.ts` drives: the
* registry surface the list read touches and a `find` that answers the overlay
* query with nothing.
*/

import { describe, it, expect } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

const APPS = ['crm', 'helpdesk', 'payroll'].map((name) => ({ name, label: name.toUpperCase(), navigation: [] }));
const OBJECTS = ['account', 'contact', 'lead'].map((name) => ({ name, label: name, fields: { name: { type: 'text' } } }));

function make() {
const contributedTo: string[] = [];
const engine: any = {
manifests: new Map(),
find: async () => [],
registry: {
listItems: (type: string) => structuredClone(type === 'app' ? APPS : type === 'object' ? OBJECTS : []),
getPackage: () => undefined,
getItem: () => undefined,
getObject: () => undefined,
getArtifactItem: () => undefined,
isPackageDisabled: () => false,
applyNavContributions: (app: any) => {
contributedTo.push(app?.name);
return app;
},
},
};
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
return { protocol, contributedTo };
}

const namesOf = (res: any): string[] => (res.items as any[]).map((it) => it.name);

describe('getMetaItems — `names` narrows the served list (#29236)', () => {
it('keeps only the named items, on `object` and on `app`', async () => {
const { protocol } = make();
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['lead', 'account'] }))).toEqual(['account', 'lead']);
expect(namesOf(await protocol.getMetaItems({ type: 'app', names: ['helpdesk'] }))).toEqual(['helpdesk']);
});

it('narrows BEFORE the per-item work after the merge: only the named apps get their nav contributions', async () => {
const { protocol, contributedTo } = make();
await protocol.getMetaItems({ type: 'app', names: ['payroll', 'crm'] });
expect(contributedTo.sort()).toEqual(['crm', 'payroll']);
});

it('a name that matches nothing is simply absent', async () => {
const { protocol } = make();
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['account', 'nope'] }))).toEqual(['account']);
expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['nope'] }))).toEqual([]);
});

it('serves each named item exactly as the whole list serves it', async () => {
const { protocol } = make();
const whole = await protocol.getMetaItems({ type: 'object' });
const named = await protocol.getMetaItems({ type: 'object', names: ['contact'] });
expect(named.items).toEqual((whole.items as any[]).filter((it) => it.name === 'contact'));
});

it('control: absent `names` lists every item, and every app gets its contributions', async () => {
const { protocol, contributedTo } = make();
expect(namesOf(await protocol.getMetaItems({ type: 'object' }))).toEqual(['account', 'contact', 'lead']);
await protocol.getMetaItems({ type: 'app' });
expect(contributedTo.sort()).toEqual(['crm', 'helpdesk', 'payroll']);
});
});
10 changes: 8 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3814,7 +3814,13 @@ export class ObjectStackProtocolImplementation implements
return throwMetadataServiceUnavailable(errors);
}

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