Skip to content

Commit 6f09464

Browse files
feat(spec): GetMetaItemsRequestSchema declares names, the batch by-names read of a /meta/:type list (#29722)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBA4TcdtpTxT1Yyhx9c83Z5C)_ Fixes #29441 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — this PR declares the protocol member only, and the generated reference (`content/docs/references/api/protocol.mdx`) teaches it; the wire parameter `?names=` is not served until the server PR on #29236, which owes the `GET /meta/:type?names=` teaching (`content/docs/api/metadata-api.mdx`, `skills/objectstack-api/SKILL.md`) ## 维护者速读 - 改了什么:`GetMetaItemsRequestSchema` 新增可选的 `names`(条目名列表),让 `/meta/:type` 列表一次只取指定的几个条目;HTTP 上写作一个逗号分隔值 `?names=a,b,c`。 - 为什么改:执行 #29236 裁决 A(6580261740,维护者「同意」),裁决要求先落 spec 这一半;服务端 PR 被本卡阻塞。调用方今天要么读整类、要么逐个按名读。 - 风险与回滚:只加一个可选成员,原来能解析的请求照样解析;空列表在 schema 层就拒收(JSON Schema 的 `minItems: 1`),不会被当成「全部」。服务端未落地前,带 `names` 的请求仍按不带处理。回滚即还原本 PR。 - 先看:`packages/spec/src/api/protocol.zod.ts`(`GetMetaItemsRequestSchema` 的 `names` 成员与上方 docblock)、`packages/spec/src/api/protocol.test.ts`(新 describe)、`.changeset/29441-meta-items-names.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/protocol.test.ts -t 'batch by-names'`,应见 7 个用例全绿;把 `.min(1, …)` 改成 `.min(0, …)` 再跑,空列表与 JSON Schema 两个用例转红。 - 需要你做的:无,席位落地。 ## What changes `packages/spec/src/api/protocol.zod.ts`, `GetMetaItemsRequestSchema` (measured at ≈:242 on `origin/main` `744211fc`): a new optional member between `packageId` and `previewDrafts`. ```ts names: z.array(z.string()) .min(1, 'Name at least one item, or leave `names` out to list every item') .optional() .describe(…) ``` The describe carries the rule only: only the named items of the type are answered, among those the list would answer without it; over HTTP it is 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, so leave the key out to list every item. No tracker number is in the describe. The schema's docblock carries the rationale: the ruling, why the member is a list while the wire is one comma string, the push-down to the store read, and why the minimum. **The member's type.** The request schema is the protocol's post-parse shape, so `names` is a list and the REST door splits the comma string. Measured sibling: `GetDataRequestSchema.select` in the same file is the post-parse list of the data route's comma-separated `select` and is typed as a string list (`transportProjectionArray(z.string())`); `GetMetaItemRequestSchema.name` is a bare `z.string()`, so the members are `z.string()` too (no name grammar: a name outside it is a name that matches no item, which is absent, not refused). **The empty-list choice: the schema refuses `[]`.** The order asked to follow what the `?id=` sibling does in spec. Measured: `?id=` has no spec member at all; its empty refusal lives only at the REST door (`refuseEmptyListFilter`, `packages/rest/src/meta-item-read-gate.ts` ≈:3483), so it answers nothing about a list member. The siblings the ruling names for the spelling (`fields`, `searchFields`) do declare it: `searchFieldsArray()` (`packages/spec/src/data/search-fields-names-a-field.ts`) is `z.array(z.string()).min(1, …)`, refusing only the empty list and judging no member, and `transportProjectionArray` refuses `[]` for `fields` / `select`. Both say why: absent means "all", so an empty list read as absent is a widening. `names` follows `searchFieldsArray` exactly: `.min(1)`, no member check. It is a minimum, not a refinement, so the published JSON Schema states it (`minItems: 1`) and `dropped-refinements.baseline.json` does not move. The door's `400` for `?names=` stays the server PR's (it is in the ruling's server workload). **Generated artifacts.** `pnpm --filter @objectstack/spec build` then `check:generated` named one stale artifact, `content/docs/references/**`; `gen:docs` regenerated it (one table row in `content/docs/references/api/protocol.mdx`). The build's `gen:schema` added `api/GetMetaItemsRequest:names` to `packages/spec/authorable-surface/api.json`. `api-surface/`, `export-origins/` and `declaration-map/` did not move: no export is added, removed or renamed (`GetMetaItemsRequestSchema` and `GetMetaItemsRequest` exist already). `check:generated` then reported all 14 artifacts current. **The declaration lines differ from the card's `Metadata-protocol: none`.** Measured: `scripts/check-changeset-no-major.mjs` refuses `none` with any `minor` entry (verdict `none-minor`) and refuses `none` whenever an `authorable-surface/` shard moves (verdict `none-baseline-moved`). This diff moves `authorable-surface/api.json`, so `none` cannot pass at any level, and the ruling's "spec minor" needs a kind. The two PRs that last moved that shard with an `api` key, #29521 (`45b4922d`) and #29100 (`f1d795b1`), declared `Clause-②: yes (widening)` + `Metadata-protocol: added` + `@objectstack/spec` minor. This PR declares the same. Seat 2 agreed and amended the claim on #29441. The `Clause-②:` line keeps the card's `(widening)` arm. **Changeset.** `.changeset/29441-meta-items-names.md`: `@objectstack/spec` minor, both declaration lines at line start. It says what the member answers, the wire spelling, the empty refusal, and that until a release carries the server change a request carrying `names` is answered as if it were absent. ## Tests `packages/spec/src/api/protocol.test.ts` gains ``describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list read')``, beside the file's other meta-read request blocks, with 7 cases: - accepts `names` and preserves the list through parse (the parsed value is asserted, since the object is non-strict and a stripped key also reads `success`); - the absent-`names` control: a request without it parses and parse invents no `names`; - refuses `[]` with one `too_small` issue at path `['names']`; - judges no member: `['no_such_app']`, `['']` and `['crm', 'crm']` parse and keep their value; - refuses the wire spelling (`'crm,helpdesk'`) and a non-string member; - `z.toJSONSchema` states `names` as `{ type: 'array', minItems: 1, items: { type: 'string' } }` and not required; - a type-level pin: `GetMetaItemsRequest['names']` is `string[] | undefined` (input and output alike, which also keeps `Iso_api_protocol__GetMetaItemsRequestSchema` in `type-alias-convention.pin.test.ts` true). <details><summary>Runs</summary> All under `scripts/pm/os-verify-lock.sh` (shared box; wall-clock figures are not idle-box figures). - On the final head `064d4e01d` (after the `origin/main` merge): `pnpm --filter @objectstack/spec test`: `Test Files 803 passed (803)`, `Tests 23793 passed | 1 todo (23794)`. `pnpm --filter @objectstack/spec typecheck`: exit 0 (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck` OK). `pnpm --filter @objectstack/spec test:repo`: `Test Files 89 passed (89)`, `Tests 1522 passed (1522)`. - On the pre-merge head `c968b47f`: spec `test` 802/802 files, 23739 tests passed; `typecheck` exit 0; `test:repo` 89/89 files, 1522 tests passed. </details> <details><summary>Ablation (one, on the tested behaviour)</summary> Committed head `c968b47f`, in a separate detached worktree so the gate run was not disturbed. Spec suites import the schema by relative `src` path, so no build or dist preflight is involved. - Control leg (unmutated): `vitest run src/api/protocol.test.ts -t 'batch by-names'`: `Tests 7 passed | 230 skipped (237)`. - Mutation via `node scripts/ablation-replace.mjs`: anchor ``.min(1, 'Name at least one item, or leave `names` out to list every item')`` → `.min(0, 'ABLATED-29441')`; the tool reports `anchor x1 -> x0`, `replace x0 -> x1`, blob `e8475ac80907 -> e261fbcb5c77`. - Result: `Tests 2 failed | 5 passed`. Failed: "refuses an EMPTY list, at `names`" and "states the minimum in the published JSON Schema" (`minItems: 0` received). Expected direction: red. - Restore: blob after restore `e8475ac80907` = HEAD blob, `git diff HEAD` empty, marker count 0. </details> ## Acceptance notes - Declared before enforced, by the ruling's order: until the server PR on #29236 lands, `getMetaItems` in `@objectstack/metadata-protocol` (inline parameter type `{ type; packageId?; previewDrafts? }`, `protocol-meta-list-reads.ts:54`) does not read `names`, and the `/meta/:type` list door has no closed query set yet, so `?names=` is ignored and the full list is answered. The ruling puts the push-down, the door's empty refusal and the closed set in that PR. - The schema judges no member. Whether the door refuses a blank segment such as `?names=a,,b` is not ruled; this PR neither refuses nor promises it, and the docblock says only that a name matching no item is absent. - `#29415` (seat 2, same file, the `cascadeDeleteObjects` describes ≈:646/:767/:2480/:2501) touches a different region; the later lander merges `origin/main` and regenerates. ## Verification Local subset, on the final head `064d4e01d` (merge of `origin/main` `3d5933a97`); the PR's check-runs carry everything CI runs. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 102 derived families (5 changed paths vs merge base `3d5933a97`), the same 102 as on the pre-merge head. Run serially, each exit code captured before any pipe: **102 exit 0**. `dispatch-gates --ran`: "102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN". - On the pre-merge head `c968b47f` the same 102 ran: 96 exit 0 first pass, 6 exit 3 (`PREREQUISITE NOT MET`: unbuilt `@objectstack/lint` / `@objectstack/formula` dist and the dist-reading gates), all 6 exit 0 after a full `turbo run build` (117/117); `--ran` reconciled 102/102. - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event` (this body): exit 0, "LEVEL AXIS: this PR declares clause-② `yes (widening)` and metadata-protocol `added`". Control leg, the same body with `Metadata-protocol: none`: exit 1, `none-baseline-moved` ("Moved: packages/spec/authorable-surface/api.json"). - `pnpm --filter @objectstack/spec check:generated` after the build of the merged head: "All 14 generated artifacts are up to date". - `node scripts/check-closing-keyword-parity.mjs --body` (this body): its report mode (exit 2) names one closing declaration, `Fixes #29441`. - `pnpm check:dev-prereqs` (local-only family): exit 0. - No export was renamed or removed, so `check-platform-checklist --changed-from` is not owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cba12d2 commit 6f09464

5 files changed

Lines changed: 121 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `GetMetaItemsRequestSchema` declares `names`, the batch by-names read of a `/meta/:type` list
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
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:
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
16+
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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,7 @@ One changed key path of the pending draft, and the last write that changed it.
15141514
| :--- | :--- | :--- | :--- |
15151515
| **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") |
15161516
| **packageId** | `string` | optional | Optional package ID to filter items by |
1517+
| **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. |
15171518
| **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. |
15181519

15191520

‎packages/spec/authorable-surface/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,7 @@
835835
"api/GetMetaItemResponse:sortability",
836836
"api/GetMetaItemResponse:type",
837837
"api/GetMetaItemResponse:version",
838+
"api/GetMetaItemsRequest:names",
838839
"api/GetMetaItemsRequest:packageId",
839840
"api/GetMetaItemsRequest:previewDrafts",
840841
"api/GetMetaItemsRequest:type",

‎packages/spec/src/api/protocol.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,74 @@ describe('environmentId stays OUT of the meta-read request shape — transport-l
18721872
});
18731873
});
18741874

1875+
// Its own import lines, as the blocks around it: this group pins one member.
1876+
import { z } from 'zod';
1877+
import type { GetMetaItemsRequest } from './protocol.zod';
1878+
1879+
describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list read', () => {
1880+
// Ruling A on #29236: the list narrows to the named items, an unknown name
1881+
// is simply absent, and an empty value is refused rather than read as every
1882+
// item. This is the protocol's post-parse shape — a list; the wire's one
1883+
// comma-separated value is split by the REST door. As in the blocks above,
1884+
// accept-pins assert the parsed VALUE: the object is non-strict, so
1885+
// `success` alone is also what an undeclared, stripped key looks like.
1886+
1887+
it('accepts names and PRESERVES the list through parse', () => {
1888+
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: ['crm', 'helpdesk'] });
1889+
expect(result.success).toBe(true);
1890+
if (result.success) expect(result.data.names).toEqual(['crm', 'helpdesk']);
1891+
});
1892+
1893+
it('keeps names OPTIONAL — a request without it lists every item, and parse invents none', () => {
1894+
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app' });
1895+
expect(result.success).toBe(true);
1896+
if (result.success) expect('names' in result.data).toBe(false);
1897+
});
1898+
1899+
it('refuses an EMPTY list, at `names` — an empty filter is never read as every item', () => {
1900+
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [] });
1901+
expect(result.success).toBe(false);
1902+
if (!result.success) {
1903+
expect(result.error.issues).toHaveLength(1);
1904+
expect(result.error.issues[0]).toMatchObject({ code: 'too_small', path: ['names'] });
1905+
}
1906+
});
1907+
1908+
it('judges no member — a name that matches no item is kept, to be simply absent from the answer', () => {
1909+
for (const names of [['no_such_app'], [''], ['crm', 'crm']]) {
1910+
const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names });
1911+
expect(result.success, JSON.stringify(names)).toBe(true);
1912+
if (result.success) expect(result.data.names).toEqual(names);
1913+
}
1914+
});
1915+
1916+
it('refuses the wire spelling — a comma string is the REST door\'s to split, not a protocol value', () => {
1917+
expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: 'crm,helpdesk' }).success).toBe(false);
1918+
expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [1] }).success).toBe(false);
1919+
});
1920+
1921+
it('states the minimum in the published JSON Schema (a minimum, not a refinement)', () => {
1922+
const json = z.toJSONSchema(GetMetaItemsRequestSchema, { io: 'input', unrepresentable: 'any' }) as {
1923+
properties: Record<string, Record<string, unknown>>;
1924+
required?: string[];
1925+
};
1926+
expect(json.properties.names).toMatchObject({ type: 'array', minItems: 1, items: { type: 'string' } });
1927+
expect(json.required ?? []).not.toContain('names');
1928+
});
1929+
1930+
it('types names as an optional string list, input and output alike', () => {
1931+
expectTypeOf<GetMetaItemsRequest['names']>().toEqualTypeOf<string[] | undefined>();
1932+
});
1933+
1934+
it('is declared ahead of its producer: the describe opens with the planned marker, never a tracker id', () => {
1935+
// No list door reads `names` yet; the change that serves it drops the
1936+
// marker and this pin with it.
1937+
const description = GetMetaItemsRequestSchema.shape.names.description ?? '';
1938+
expect(description).toMatch(/^\[PLANNED — not yet served\] /);
1939+
expect(description).not.toMatch(/#\d{3,}/);
1940+
});
1941+
});
1942+
18751943
describe('MetadataProtocol declares getMetaItemLayered', () => {
18761944
// Type-level pins (compiled by the spec test typecheck, the
18771945
// translation-typegen.test.ts pattern). The member is an interface

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,45 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({
238238
* must carry it alongside a request do so in a typed transport envelope at
239239
* their own layer (see `TransportScopedMetaRequest` in `@objectstack/rest`),
240240
* never by widening these shapes.
241+
*
242+
* **`names` is the batch by-names read of one type** (ruling A on #29236,
243+
* record 6580261740): a caller that wants a handful of items asks the list
244+
* once instead of reading the whole type, or reading each item by name. This
245+
* member is the protocol's post-parse shape — a LIST of item names; the wire
246+
* spelling `?names=a,b,c` (one comma-separated value, the data routes'
247+
* convention for `fields` and `searchFields`, which fits the list door's arity
248+
* rule that refuses a repeated key) is split by the REST door, not here. The
249+
* protocol pushes it down to the store read, so it filters before decorating
250+
* and masking and translation run on the named items only.
251+
*
252+
* The list arm refuses only the EMPTY list, as `searchFieldsArray` does
253+
* (`../data/search-fields-names-a-field.ts`): absent means "every item", so an
254+
* empty filter read as absent is a widening — the class the list door's `?id=`
255+
* / `?object=` refusal closes (`refuseEmptyListFilter` in `@objectstack/rest`).
256+
* The schema judges no member: a name that matches no item is not refused,
257+
* it is simply absent from the answer, as an unknown `?id=` narrows to the
258+
* empty list. A minimum, not a refinement, so the published JSON Schema
259+
* states it.
260+
*
261+
* **Declared ahead of its producer.** No list door reads `names` yet, so its
262+
* describe opens `[PLANNED — not yet served]`; the server PR on #29236 drops
263+
* the marker in the change that serves the member (the pin is in
264+
* `protocol.test.ts`).
241265
*/
242266
export const GetMetaItemsRequestSchema = lazySchema(() => z.object({
243267
type: z.string().describe('Metadata type name (e.g., "object", "plugin")'),
244268
packageId: z.string().optional().describe('Optional package ID to filter items by'),
269+
names: z.array(z.string())
270+
.min(1, 'Name at least one item, or leave `names` out to list every item')
271+
.optional()
272+
.describe(
273+
'[PLANNED — not yet served] Item names to narrow the list to: only the named items of the type '
274+
+ 'are answered, among those the list would answer without it. Sent over HTTP as one '
275+
+ 'comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the '
276+
+ 'answer (no 404). An empty value is refused with 400 rather than read as every item; leave the '
277+
+ 'key out to list every item. No list door reads it yet: until one does, a request carrying it '
278+
+ 'is answered as if it were absent.',
279+
),
245280
previewDrafts: z.boolean().optional().describe(
246281
'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, '
247282
+ 'pending `state=\'draft\'` rows are overlaid on the active list — draft '

0 commit comments

Comments
 (0)