diff --git a/.changeset/getmetaitems-helper-request-literals-typed.md b/.changeset/getmetaitems-helper-request-literals-typed.md new file mode 100644 index 0000000000..d38062235f --- /dev/null +++ b/.changeset/getmetaitems-helper-request-literals-typed.md @@ -0,0 +1,43 @@ +--- +"@objectstack/rest": patch +--- + +refactor(rest): the non-door `getMetaItems` request literals are compiled against the declared contract (#9805) + +Nine `getMetaItems` call sites in `packages/rest/src/rest-server.ts` outside the +four meta-read doors still passed their request through `as any` (or through a +`p: any` parameter), so the compiler checked nothing about them. Every member +they thread has been expressible in declared types since #9741 landed +`previewDrafts` on `GetMetaItemsRequest` and the `TransportScopedMetaRequest` +envelope for the transport-level `environmentId` — the casts were pure +blindness, and an un-typed request literal is exactly the class that lets a +future key drift silently. + +Each literal is now a named const typed +`TransportScopedMetaRequest` (or plain +`GetMetaItemsRequest` where the site threads no `environmentId`), the same shape +#9741 gave the doors: the object-metadata read behind the API-exposure gate, the +audience book fetch, the book-tree book and doc listings, the doc corpus behind +the audience resolver, the public-form view lookup, the public-form object +schema, the public-lookup reference resolution, and the dataset listing. + +**No behaviour change of any kind, and nothing about the wire moves.** The +outgoing payloads are byte-identical (same keys, same conditional spreads); the +edit hoists each literal into a const and drops a type-level cast. Two spellings +at these sites deliberately SURVIVE, because retiring either would change +behaviour rather than typing, and both are now documented on the envelope alias: + +- the optional call (`getMetaItems?.(…)`) and the `typeof … === 'function'` + guards — `getMetaItems` is a required `MetadataProtocol` member, so these are + not feature detection in the type sense, but a host may occupy the protocol + slot with an object that does not implement the whole surface (the reason + `metaTypeIsLive` documents the same spelling for `getMetaTypes`). Retiring one + turns a tolerated absence into a `TypeError`; +- the result handling — the verb is declared to return `{ type, items }` while + these sites also tolerate the bare-array shape older hosts and stubs return, + so the response stays runtime-shaped on purpose. + +Genuinely feature-detected server-only verbs (`getMetaDiagnostics`, +`listDrafts`, `migrateStoredMetadata`, …) are untouched — runtime casts are the +documented convention there, and tightening one would turn optional capability +detection into a hard dependency. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 7247472662..bd195a22c1 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -134,6 +134,28 @@ export type RestProtocol = DataProtocol & MetadataProtocol; * is now checked against the spec contract — an undeclared member is a compile * error at the call site, not a cast-and-hope. Never add protocol members * here; a key that belongs to the request belongs in the spec schema. + * + * [#9805] The same typing now covers the NON-door `getMetaItems` helper call + * sites in this file (the object, book, doc, view and dataset listings). Two + * spellings those sites carry deliberately SURVIVE the tightening, because + * retiring either would change behaviour rather than typing: + * + * - The OPTIONAL CALL (`getMetaItems?.(…)`) and the + * `typeof … === 'function'` guards. `getMetaItems` is a REQUIRED + * `MetadataProtocol` member, so these are not feature detection in the + * type sense — but a host may occupy the protocol slot with an object + * that does not implement the whole surface, which is the measured reason + * they exist (`metaTypeIsLive` documents the same deliberate spelling for + * `getMetaTypes`). Retiring one turns a tolerated absence into a + * `TypeError`. + * - The RESULT handling. `getMetaItems` is declared to return + * `{ type, items }`, while these sites also tolerate the bare-array shape + * older hosts and stubs return (see `metaItemsArray`), so the response + * stays runtime-shaped on purpose. + * + * The REQUEST is what is fully typeable today, and the request is what is + * typed — which is the whole point: an undeclared key in one of these + * literals is now a compile error instead of a cast-and-hope. */ type TransportScopedMetaRequest = R & { environmentId?: string }; import { @@ -1329,10 +1351,11 @@ export class RestServer { */ private async loadObjectItems(p: RestProtocol, environmentId: string | undefined): Promise { try { - const r: any = await (p as any).getMetaItems?.({ + const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - }); + }; + const r: any = await p.getMetaItems?.(objectsRequest); return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; } catch (err) { // [#3545] The API-exposure gate fails OPEN when object metadata can't @@ -1711,11 +1734,12 @@ export class RestServer { } /** Fetch every book of the environment, shaped for the audience resolver. */ - private async fetchAudienceBooks(p: any, environmentId: string | undefined): Promise { - const raw = await p.getMetaItems({ + private async fetchAudienceBooks(p: RestProtocol, environmentId: string | undefined): Promise { + const booksRequest: TransportScopedMetaRequest = { type: 'book', ...(environmentId ? { environmentId } : {}), - } as any).catch(() => []); + }; + const raw = await p.getMetaItems(booksRequest).catch(() => []); return RestServer.metaItemsArray(raw).map((b: any) => b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b, ); @@ -4554,11 +4578,12 @@ export class RestServer { const norm = (raw: any): any[] => Array.isArray(raw) ? raw : (raw && Array.isArray(raw.items) ? raw.items : []); - const books = norm(await prot.getMetaItems({ + const booksRequest: TransportScopedMetaRequest = { type: 'book', ...(packageId ? { packageId } : {}), ...(environmentId ? { environmentId } : {}), - } as any)); + }; + const books = norm(await prot.getMetaItems(booksRequest)); let book = books.find((b: any) => b && b.name === req.params.name); if (!book) { // Unknown name → the implicit per-package book (§6.4). @@ -4583,11 +4608,12 @@ export class RestServer { return; } - const docs = norm(await prot.getMetaItems({ + const docsRequest: TransportScopedMetaRequest = { type: 'doc', ...(packageId ? { packageId } : {}), ...(environmentId ? { environmentId } : {}), - } as any)) + }; + const docs = norm(await prot.getMetaItems(docsRequest)) .map((d: any) => (d && typeof d === 'object' ? resolveDocLocale(d, locale) : d)) .map((d: any) => ({ name: d.name, @@ -5135,10 +5161,12 @@ export class RestServer { if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) { allowed = true; // no gated book anywhere → org suffices } else { - const corpus = RestServer.metaItemsArray(await p.getMetaItems({ + const docCorpusRequest: TransportScopedMetaRequest = { type: 'doc', ...(environmentId ? { environmentId } : {}), - } as any).catch(() => [])) + }; + const corpus = RestServer.metaItemsArray( + await p.getMetaItems(docCorpusRequest).catch(() => [])) .filter((d: any) => d && typeof d === 'object') .map((d: any) => ({ name: d.name, @@ -8028,10 +8056,11 @@ export class RestServer { ): Promise<{ view: any; form: any; object: string } | null> => { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; - const result: any = await (p as any).getMetaItems({ + const viewsRequest: TransportScopedMetaRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), - }); + }; + const result: any = await p.getMetaItems(viewsRequest); const items: any[] = Array.isArray(result?.items) ? result.items : Array.isArray(result) @@ -8092,10 +8121,11 @@ export class RestServer { try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { - const r: any = await (p as any).getMetaItems({ + const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - }); + }; + const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; const obj = items.find((o: any) => o?.name === match.object); if (obj && obj.fields && typeof obj.fields === 'object') { @@ -8408,10 +8438,11 @@ export class RestServer { let referenceTo: string | undefined = picker.object; if (!referenceTo && typeof (p as any).getMetaItems === 'function') { try { - const r: any = await (p as any).getMetaItems({ + const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - }); + }; + const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; const obj = items.find((o: any) => o?.name === match.object); const def = obj?.fields?.[fieldName]; @@ -8611,7 +8642,8 @@ export class RestServer { let dataset = body.dataset; if (!dataset && body.datasetName) { const p = await this.resolveProtocol(environmentId, req); - const items = await (p as any).getMetaItems?.({ type: 'dataset', previewDrafts }).catch(() => null); + const datasetRequest: GetMetaItemsRequest = { type: 'dataset', previewDrafts }; + const items: any = await p.getMetaItems?.(datasetRequest).catch(() => null); const list = Array.isArray(items?.items) ? items.items : (Array.isArray(items) ? items : []); dataset = list.find((d: any) => d?.name === body.datasetName); if (!dataset) {