diff --git a/.changeset/29441-meta-items-names.md b/.changeset/29441-meta-items-names.md new file mode 100644 index 00000000000..5aa679aca8c --- /dev/null +++ b/.changeset/29441-meta-items-names.md @@ -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. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 6dc761fcc64..638e4013d63 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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. | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5593fb514f7..0c8871c6c36 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -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", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index d51cdc1c963..e265f4a725b 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -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>; + 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().toEqualTypeOf(); + }); + + 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 diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 744b13f7428..c96f948e174 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -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 '