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
16 changes: 16 additions & 0 deletions .changeset/29441-meta-items-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
---

feat(spec): `GetMetaItemsRequestSchema` declares `names`, the batch by-names read of a `/meta/:type` list

Clause-②: yes (widening)
Metadata-protocol: added

A caller that wants a handful of items of one metadata type used to read the whole type, or read each item by name. The list request now declares an optional `names`, a list of item names:

- **What it answers.** Only the named items of the type, among those the list would answer without it. A name that matches no item is simply absent from the answer; there is no 404.
- **On the wire.** One comma-separated value, `GET /api/v1/meta/:type?names=a,b,c`, the data routes' convention for `fields` and `searchFields`. The request member is the protocol's post-parse shape, a list (`names: ['a', 'b', 'c']`); the REST door splits the value.
- **An empty value is refused.** The schema refuses an empty list (`minItems: 1` in the published JSON Schema), and the door answers an empty `?names=` with 400, rather than reading it as every item. Leave the key out to list every item.

This entry declares the member only. The REST door, the dispatcher and the store read that serve it are a separate change to `@objectstack/rest`, `@objectstack/runtime` and `@objectstack/metadata-protocol`; until a release carries it, a list request carrying `names` is answered as if it were absent.
1 change: 1 addition & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,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. |
| **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
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@
"api/GetMetaItemResponse:sortability",
"api/GetMetaItemResponse:type",
"api/GetMetaItemResponse:version",
"api/GetMetaItemsRequest:names",
"api/GetMetaItemsRequest:packageId",
"api/GetMetaItemsRequest:previewDrafts",
"api/GetMetaItemsRequest:type",
Expand Down
68 changes: 68 additions & 0 deletions packages/spec/src/api/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1872,6 +1872,74 @@ describe('environmentId stays OUT of the meta-read request shape — transport-l
});
});

// Its own import lines, as the blocks around it: this group pins one member.
import { z } from 'zod';
import type { GetMetaItemsRequest } from './protocol.zod';

describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list read', () => {
// Ruling A on #29236: the list narrows to the named items, an unknown name
// is simply absent, and an empty value is refused rather than read as every
// item. This is the protocol's post-parse shape — a list; the wire's one
// comma-separated value is split by the REST door. As in the blocks above,
// accept-pins assert the parsed VALUE: the object is non-strict, so
// `success` alone is also what an undeclared, stripped key looks like.

it('accepts names and PRESERVES the list through parse', () => {
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: ['crm', 'helpdesk'] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.names).toEqual(['crm', 'helpdesk']);
});

it('keeps names OPTIONAL — a request without it lists every item, and parse invents none', () => {
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app' });
expect(result.success).toBe(true);
if (result.success) expect('names' in result.data).toBe(false);
});

it('refuses an EMPTY list, at `names` — an empty filter is never read as every item', () => {
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [] });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toHaveLength(1);
expect(result.error.issues[0]).toMatchObject({ code: 'too_small', path: ['names'] });
}
});

it('judges no member — a name that matches no item is kept, to be simply absent from the answer', () => {
for (const names of [['no_such_app'], [''], ['crm', 'crm']]) {
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names });
expect(result.success, JSON.stringify(names)).toBe(true);
if (result.success) expect(result.data.names).toEqual(names);
}
});

it('refuses the wire spelling — a comma string is the REST door\'s to split, not a protocol value', () => {
expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: 'crm,helpdesk' }).success).toBe(false);
expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [1] }).success).toBe(false);
});

it('states the minimum in the published JSON Schema (a minimum, not a refinement)', () => {
const json = z.toJSONSchema(GetMetaItemsRequestSchema, { io: 'input', unrepresentable: 'any' }) as {
properties: Record<string, Record<string, unknown>>;
required?: string[];
};
expect(json.properties.names).toMatchObject({ type: 'array', minItems: 1, items: { type: 'string' } });
expect(json.required ?? []).not.toContain('names');
});

it('types names as an optional string list, input and output alike', () => {
expectTypeOf<GetMetaItemsRequest['names']>().toEqualTypeOf<string[] | undefined>();
});

it('is declared ahead of its producer: the describe opens with the planned marker, never a tracker id', () => {
// No list door reads `names` yet; the change that serves it drops the
// marker and this pin with it.
const description = GetMetaItemsRequestSchema.shape.names.description ?? '';
expect(description).toMatch(/^\[PLANNED — not yet served\] /);
expect(description).not.toMatch(/#\d{3,}/);
});
});

describe('MetadataProtocol declares getMetaItemLayered', () => {
// Type-level pins (compiled by the spec test typecheck, the
// translation-typegen.test.ts pattern). The member is an interface
Expand Down
35 changes: 35 additions & 0 deletions packages/spec/src/api/protocol.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,45 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({
* must carry it alongside a request do so in a typed transport envelope at
* their own layer (see `TransportScopedMetaRequest` in `@objectstack/rest`),
* never by widening these shapes.
*
* **`names` is the batch by-names read of one type** (ruling A on #29236,
* record 6580261740): a caller that wants a handful of items asks the list
* once instead of reading the whole type, or reading each item by name. This
* member is the protocol's post-parse shape — a LIST of item names; the wire
* spelling `?names=a,b,c` (one comma-separated value, the data routes'
* convention for `fields` and `searchFields`, which fits the list door's arity
* rule that refuses a repeated key) is split by the REST door, not here. The
* protocol pushes it down to the store read, so it filters before decorating
* and masking and translation run on the named items only.
*
* The list arm refuses only the EMPTY list, as `searchFieldsArray` does
* (`../data/search-fields-names-a-field.ts`): absent means "every item", so an
* empty filter read as absent is a widening — the class the list door's `?id=`
* / `?object=` refusal closes (`refuseEmptyListFilter` in `@objectstack/rest`).
* The schema judges no member: a name that matches no item is not refused,
* it is simply absent from the answer, as an unknown `?id=` narrows to the
* empty list. A minimum, not a refinement, so the published JSON Schema
* states it.
*
* **Declared ahead of its producer.** No list door reads `names` yet, so its
* describe opens `[PLANNED — not yet served]`; the server PR on #29236 drops
* the marker in the change that serves the member (the pin is in
* `protocol.test.ts`).
*/
export const GetMetaItemsRequestSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name (e.g., "object", "plugin")'),
packageId: z.string().optional().describe('Optional package ID to filter items by'),
names: z.array(z.string())
.min(1, 'Name at least one item, or leave `names` out to list every item')
.optional()
.describe(
'[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.',
),
previewDrafts: z.boolean().optional().describe(
'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, '
+ 'pending `state=\'draft\'` rows are overlaid on the active list — draft '
Expand Down
Loading