Skip to content

Commit 8441a01

Browse files
feat(rest): one request loads an app's metadata — GET /meta/app/{appId}/bundle, per caller, with an ETag (#28287)
domain:spec#1 · os-tesla · session_01L9qibwM9YxDuiRDQYmA1Yd <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoCx7hpZ43LGt375Y5Yd8iPe)_ Part of #27627 Claim: 6538108232 Clause-②: yes (widening: a new route, its response shape and a new client method; no existing route, key or answer changes) Metadata-protocol: added Skills-docs: none edited. No skill teaches which metadata requests a console sends while it works inside an app, and the API skill's spec index (`skills/objectstack-api/references/_index.md`) names no `api/` schema file, so the new `api/app-bundle.zod.ts` needs no row. Named, not edited. ## 维护者速读 - **改了什么:** 新增 `GET /api/v1/meta/app/{appId}/bundle`,一次请求返回控制台在一个应用里要用的元数据:应用本身和导航、导航能到达的对象及其一跳内的入向关联对象、这些对象的视图、页面,以及导航点名的仪表盘。响应带按调用者计算的弱 ETag(与其他 `/meta` 读取共用 #28320 的同一个验证器),带 `If-None-Match` 且内容没变就回 304;`@objectstack/client` 加 `meta.getAppBundle(appId, { ifNoneMatch })`,并顺手修好 `meta.getCached` 收到 304 时抛错、走不到自己 304 分支的问题(席位要求)。 - **为什么改:** PR 1(#27713)把打开记录的数据请求合成了一个,但每打开一个对象仍要单独读一次元数据;云端每个请求本身就要 0.5–1.4 秒,元数据一次取齐、会话内只做 304 校验,打开记录才不再等元数据。 - **风险与回滚:** 包里每一项都以同一调用者身份走单项读取 `GET /meta/{类型}/{名称}` 的同一条链路 `createMetaItemAnswer`(同一个读取闸门、字段可见性屏蔽、语言翻译),没有第二份闸门;看不到的项不出现,看不到应用就按应用读取的原样拒绝整个请求。ETag 由共用验证器对发给这个调用者的响应体求哈希,两人只有拿到完全相同的内容才共用一个 ETag;字段可见性无法判定时改为 `private, no-store` 且不发 ETag。原有路由不变,回滚 = revert 本 PR。 - **先看:** `packages/rest/src/meta-app-bundle-routes.ts`(成员推导、逐项过闸门、验证器与 304),然后 `packages/rest/src/meta-app-bundle-routes.test.ts` 里“每一项与它自己的单项读取逐字节相同”那条。 - **两分钟验证:** `cd packages/rest && npx vitest run src/meta-app-bundle-routes.test.ts` → 15 个用例绿;把交给共用验证器的内容改成只有键名(与调用者无关)会让“ETag 等于响应体的哈希”“不同闸门得到不同 ETag”和“304/另一调用者的 ETag 回 200”三条变红。 - **需要你做的:** 无,席位落地。 ## What changes - **The shape** (`AppMetadataBundleResponseSchema`, `@objectstack/spec/api`, in `packages/spec/src/api/app-bundle.zod.ts` beside PR 1's `record-page.zod.ts`): `{ app, objects, views, pages, dashboards }`, answered bare like the `/meta` item reads. `app` and every list entry are `GetMetaItemResponseSchema`, the envelope the member's own `GET /meta/{type}/{name}` answers. The module header states what each list holds and in which order, the absence rule and the cache contract. - **The route** (`packages/rest/src/meta-app-bundle-routes.ts`, registered inside the `/meta` umbrella on the per-item switch `metadata.endpoints.item`, so the anonymous deny and the type read admission wrap it like every `/meta` read): - **Members.** The app is read first. Its navigation is walked at every depth (top-level `navigation`, `areas[].navigation`, `children`): `object` entries name objects and their `viewName`, `page` entries name pages, `dashboard` entries name dashboards. Objects are the navigation's, then one hop of inbound relations (an object with a `lookup` or `master_detail` field whose `reference` is a reached object). Views are the navigation's named views plus every view that binds to a served object (`deriveViewContainerObject`); pages are the navigation's plus every page whose `object` is a served object; dashboards are the navigation's. - **One gate.** Every member is `createMetaItemAnswer` over `p.getMetaItem({ type, name })` with the rendered read's policy `{ arms: 'all', app: 'gate' }` and the member type's own field-visibility posture: the same absence check, read gate, doc locale collapse, field mask and translation the item read runs. A member the chain refuses or finds absent is left out, with nothing in the body naming it. An app the caller may not read, or an unknown app, answers exactly what `GET /meta/app/{appId}` answers. A field-visibility evaluation fault answers the item read's 503. - **Validator.** The `/meta` reads' shared one from #28320: the body is sent by `sendMetaRepresentation(req, res, body, metaRepresentationCaching(body, undetermined), metadata.enableCache !== false)`, so the `ETag` is the weak `representationEtag` of the served body, computed after every gate and therefore per caller by construction. `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and a matching `If-None-Match` (compared weakly) answers `304` with no body. A member served under an undetermined field visibility is one the item chain issued no etag; its `private, no-store` becomes the whole bundle's, with no `ETag`. `metadata.enableCache: false` sends no validator, as it does for the item reads. The route has no validator rule of its own. - **Server-Timing.** The member reads run inside `measureServerTiming('meta', …, 'Metadata reads')`, so a `meta` span sits beside `db` and `total` under the existing gate. Opening the header to every caller is out of scope. - **Closed query set.** `refuseUnknownQueryParams` with an empty set: any query parameter is a 400 before anything is read. - **`rest-server.ts`:** `metaItemAnswerSources` takes the member's type as an optional fifth argument (defaulting to `req.params.type`, so the item routes are unchanged), and the bundle route is registered after the item reads. - **The client** (`packages/client/src/index.ts`): `meta.getAppBundle(appId, { ifNoneMatch })` answers `AppBundleCacheResponse`, `{ notModified: false, data, etag }` on a `200` and `{ notModified: true, etag }` on a `304`. It follows the spelling of the existing conditional read `meta.getCached` (`ifNoneMatch` in, `notModified` and `etag: { value, weak }` out). The private `fetch` gains an opt-in `admitNotModified` so a `304` is answered instead of thrown. `meta.getCached` passes it too, which fixes its unreachable `304` branch (see Acceptance notes). - **Ledger and censuses:** a `GET /api/v1/meta/app/:appId/bundle` row in `rest-route-ledger.ts` (`sdk`, `meta.getAppBundle`, `AppMetadataBundleResponseSchema`); the new module on `REST_SERVER_REGISTRAR_FILES` and `REST_SERVER_META_ROUTE_FILES`; its canonical-query (0) and docblock (16) floors; the mount-table pin's `metadata.endpoints.item` and `api.enableMetadata` rows; a `check-route-envelope` MODULES row, ratcheted on #9559 like `record-page-routes.ts`. - Generated: `api-surface/`, `authorable-surface/`, `declaration-map/`, `export-origins/`, `json-schema.manifest/`, `content/docs/references/api/app-bundle.mdx` and the reference indexes, the strictness-ledger count, `llms.txt` counts. - Changeset `.changeset/27627-app-metadata-bundle.md`, `minor` for `@objectstack/spec`, `@objectstack/rest` and `@objectstack/client`. ## Tests - `packages/spec/src/api/app-bundle.test.ts` (3): the shape takes the app and four lists of item envelopes; each list's element is `GetMetaItemResponseSchema` itself; a missing list, an entry without its envelope and an undeclared key are refused. - `packages/rest/src/meta-app-bundle-routes.test.ts` (15), on a real `RestServer` mount with a CRM app (navigation with a gated object, a page, two dashboards one gated, and an area) and a gated payroll app: - the navigation walk and the inbound-relation hop, and the empty closed query set; - the admin's bundle holds every member in order and parses against the named schema; - **every member is byte-equal to that caller's own `GET /meta/{type}/{name}`**, for both callers; - **a caller without read gets a bundle without the item**: the member's bundle has no gated object, no object reached only through it, no gated view and no gated dashboard, and no member names them (the app's own read still lists the gated dashboard in its navigation, as `GET /meta/app/crm` does); - a Server-Timing `meta` mark is recorded; - the `ETag` is exactly `representationEtag` of the served body, weak, under `private, no-cache`; - **differing gates give different ETags**, and the same caller twice gives the same one; - **an unchanged bundle answers 304** (with the tag as sent, and with its bare opaque form), while another caller's ETag or a changed store answers 200 with a new ETag; - `metadata.enableCache: false` sends no validator and answers a matching `If-None-Match` in full; - an undetermined field-visibility posture serves `private, no-store` with no ETag; - **an unknown app gets the same refusal as `GET /meta/app/{unknown}`** (status and body), and only the app is read; a gated app gets the app read's 403; - an unknown query parameter is a 400 with nothing read. - `packages/client/src/meta-app-bundle.test.ts` (3): the URL is encoded and the 200 answers data and etag; `ifNoneMatch` is sent and **a 304 answers `notModified` instead of throwing**; a 403 still rejects with the server's status and code; `meta.getCached` answers a 304 as `{ notModified: true }` with the validator it sent, and a 404 still rejects. Ablation: dropping the opt-in from `getCached` turns that 304 test red, and the restore brings back HEAD's blob. - **Ablation of the per-caller fold:** `node scripts/ablation-replace.mjs` swapped `metaRepresentationCaching(body, undetermined)` for `metaRepresentationCaching(Object.keys(body), undetermined)`, a validator input that no longer depends on what this caller was served. 3 of 15 went red (the `representationEtag` pin, "two callers whose gates differ are answered two validators", and "an unchanged bundle answers 304 … another caller's validator, or a changed store, is answered in full"). The tool restored the blob to HEAD, and the rerun is 15/15 green. (On the earlier head, before the shared helper, the same ablation on the route's own validator turned 2 of 14 red.) ## Acceptance notes - **Validator, a declared deviation from the order's "fold the same dimensions":** the ETag hashes the served body after every gate instead of folding the per-dimension fingerprints. Two callers share it exactly when they are served the same body, which the fold could only approximate. Spec seat 1 confirmed this rule. Since #28320 landed, the bundle uses that PR's shared helper rather than its own copy of the rule, so the `ETag` is weak `W/"…"` like every other `/meta` read's. The members' bytes did not change. - **Runtime dispatcher:** the bundle is served by the `RestServer` route only. The runtime dispatcher's `/meta` catch-all does not serve it, the same as PR 1's record-page route. - **Dashboards** come from the navigation only. A dashboard has no object binding to follow, so no second rule adds them. - **Deviation, added at spec seat 1's request: `meta.getCached`'s `304` is fixed here.** Its `304` branch in `packages/client` was unreachable, because the private `fetch` threw on any non-`ok` status before the branch ran, so a matching `If-None-Match` rejected instead of answering `notModified`. It now passes the same `admitNotModified` opt-in as `getAppBundle`. The one consumer, `useMetadata`'s cached arm in `@objectstack/client-react`, already branches on `notModified`, and its suite is 34/34. - **Out of scope per the order:** the console consumer (ui seat 2, which is bound to this shape), opening Server-Timing to every caller, and cloud#3378. ## Verification Head `170b86ad`, merged with main at `f49b4787` (which brings #28320). - **Tests:** route 15/15; full `packages/rest` suite 361 files, 7148 passed; client suite 687/687 and `client-react` 34/34 at `1e7a1b10` (the client is unchanged since); dogfood `authz-probe-blind-spot` and `authz-conformance` 70/70 at `28ac68c2`. - **Typecheck:** `tsc --noEmit` in `packages/rest`, `packages/client` and `packages/spec`, all exit 0. - **Generated artifacts:** the spec header's wording change regenerated `content/docs/references/api/app-bundle.mdx` (`check:generated --fix`, which rebuilt the spec dist first, then exit 0); `check:route-envelope` and its self-test exit 0. - **Derived gates on `170b86ad`:** `node scripts/pm/dispatch-gates.mjs --commands` named the same 125 commands as before. Every one was run with stdin closed and its exit recorded, and all 125 exit 0. The ones that read built output, plus `check:docs` and `check:generated`, first recorded exit 3, which means "not measured", not a finding. They were rerun after the spec, client and client-react rebuilds, the page regeneration, and a `pnpm install --frozen-lockfile` for the workspace link the merged main added to `@objectstack/cloud-connection`. `--ran` reconciliation: "125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. The earlier run on `28ac68c2` was also 125 of 125. Its `check:doc-authoring` caught a real `#27627` in the ledger row's `note:` string, and that fix is in this head. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8771726 commit 8441a01

26 files changed

Lines changed: 1216 additions & 14 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/client': minor
5+
---
6+
7+
feat(rest): `GET /api/v1/meta/app/{appId}/bundle` answers an app's metadata in one request, per caller, with an ETag (#27627)
8+
9+
Clause-②: yes (widening: a new route and its response shape; no existing route, key or answer changes)
10+
Metadata-protocol: added
11+
12+
A console working inside one app used to read the app, then one `GET /meta/object/{name}` per object it opened, then the views, pages and dashboards those draw, each as its own request. The new route answers them together (`AppMetadataBundleResponseSchema`, `@objectstack/spec/api`):
13+
14+
- **What is in it**: the app with its navigation; every object its navigation reaches (top-level, area and nested entries) and every object with a lookup or master-detail field referencing one of those; the views the navigation names or that bind to a bundled object; the pages the navigation names or that bind to a bundled object; the dashboards the navigation names.
15+
- **Every member is the item read it replaces, as the same caller**: the app is `GET /meta/app/{appId}`, and each entry is `GET /meta/{type}/{name}`, envelope included. Audience gates, list-view and dashboard permissions, the field-visibility mask, the translation to the request locale and `sortability` apply exactly as they apply to those reads. An item the caller may not read is absent from the bundle. An app the caller may not read refuses the whole request with the app read's own answer.
16+
- **Cached per caller**: a weak `ETag` over the body served to this caller (the `/meta` reads' shared rule), `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and `If-None-Match` answered `304`. Two callers share a validator only when they are served the same body. A member whose field visibility could not be determined serves the bundle `private, no-store` with no `ETag`.
17+
- **Server-Timing**: the bundle's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin).
18+
- The query-parameter set is closed and empty.
19+
- `@objectstack/client` gains `meta.getAppBundle(appId, { ifNoneMatch })`, conditional in `meta.getCached`'s spelling: it answers `{ notModified: false, data, etag }` on a `200` and `{ notModified: true, etag }` on a `304`, and a `304` is not thrown as a failed request.
20+
- `meta.getCached` now answers a matching `If-None-Match` / `If-Modified-Since` with `{ notModified: true }` through the same opt-in. Before, the transport threw on the `304` before the method's own `304` branch could run.

‎content/docs/references/api/app-bundle.mdx‎

Lines changed: 198 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: API Protocol — complete schema reference
33
navTitle: API Protocol
4-
description: "The ObjectStack API Protocol in 33 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
4+
description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -10,6 +10,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
1010

1111
<Cards>
1212
<Card href="/docs/references/api/analytics" title="Analytics" description="Source: packages/spec/src/api/analytics.zod.ts" />
13+
<Card href="/docs/references/api/app-bundle" title="App Bundle" description="Source: packages/spec/src/api/app-bundle.zod.ts" />
1314
<Card href="/docs/references/api/auth" title="Auth" description="Source: packages/spec/src/api/auth.zod.ts" />
1415
<Card href="/docs/references/api/auth-endpoints" title="Auth Endpoints" description="Source: packages/spec/src/api/auth-endpoints.zod.ts" />
1516
<Card href="/docs/references/api/automation-api" title="Automation Api" description="Source: packages/spec/src/api/automation-api.zod.ts" />

‎content/docs/references/api/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"plugin-rest-api",
3333
"storage",
3434
"---More---",
35+
"app-bundle",
3536
"error-code-ledger",
3637
"misc",
3738
"package-api-assembled",

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1571 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
2121
| Module | Pages | Schemas | Description |
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
24-
| [API Protocol](/docs/references/api) | 33 | 451 | REST contracts, endpoints, routing, realtime, batch, discovery. |
24+
| [API Protocol](/docs/references/api) | 34 | 452 | REST contracts, endpoints, routing, realtime, batch, discovery. |
2525
| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
@@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **203** | **1570** | 14 protocol modules |
37+
| **Total** | **204** | **1571** | 14 protocol modules |
3838

3939
---
4040

@@ -63,13 +63,14 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
6363

6464
## API Protocol
6565

66-
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **33 pages, 451 schemas**
66+
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 452 schemas**
6767

6868
REST contracts, endpoints, routing, realtime, batch, discovery.
6969

7070
| File | Schemas |
7171
| :--- | :--- |
7272
| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` |
73+
| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` |
7374
| [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` |
7475
| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` |
7576
| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 460 |
22+
| `api/` | 461 |

‎packages/client/src/index.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
GetRecordFieldAccessResponse,
88
RecordPageRequest,
99
RecordPageResponse,
10+
AppMetadataBundleResponse,
1011
UpdateManyRequest,
1112
DeleteManyRequest,
1213
BatchOptions,
@@ -252,6 +253,15 @@ export interface ClientConfig {
252253
*/
253254
export type DiscoveryResult = GetDiscoveryResponse;
254255

256+
/**
257+
* [#27627] `meta.getAppBundle`'s answer: the bundle and its validator, or
258+
* `notModified` when the `ifNoneMatch` sent still names what the server would
259+
* serve this caller.
260+
*/
261+
export type AppBundleCacheResponse =
262+
| { notModified: false; data: AppMetadataBundleResponse; etag?: { value: string; weak: boolean } }
263+
| { notModified: true; etag?: { value: string; weak: boolean } };
264+
255265
/**
256266
* @deprecated Use `data.query()` with standard QueryAST parameters instead.
257267
* This interface uses legacy parameter names (filter/sort/top/skip) that
@@ -2092,9 +2102,11 @@ export class ObjectStackClient {
20922102
headers['If-Modified-Since'] = cacheOptions.ifModifiedSince;
20932103
}
20942104

2105+
// [#27627] The 304 is admitted by the transport; without it `fetch` threw on the
2106+
// non-ok status and the branch below was unreachable.
20952107
const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`, {
20962108
headers
2097-
});
2109+
}, { admitNotModified: true });
20982110

20992111
// Check for 304 Not Modified
21002112
if (res.status === 304) {
@@ -2262,6 +2274,33 @@ export class ObjectStackClient {
22622274
return this.unwrapResponse<FindReferencesToMetaResponse>(res);
22632275
},
22642276

2277+
/**
2278+
* [#27627] The app metadata bundle: the app and the objects, views, pages
2279+
* and dashboards its navigation reaches, each as its own
2280+
* `GET /meta/{type}/{name}` answers it to this caller, in one request
2281+
* (`GET /meta/app/{appId}/bundle`). An item the caller may not read is
2282+
* absent; an app the caller may not read rejects with the app read's own
2283+
* answer. The response carries a per-caller `ETag` under
2284+
* `Cache-Control: private, no-cache`, so the platform's HTTP cache
2285+
* revalidates it with `If-None-Match`.
2286+
*/
2287+
getAppBundle: async (appId: string, cacheOptions?: { ifNoneMatch?: string }): Promise<AppBundleCacheResponse> => {
2288+
// The conditional spelling is `getCached`'s (`MetadataCacheRequest.ifNoneMatch` in,
2289+
// `notModified` + `etag: { value, weak }` out), with the 304 admitted by the transport
2290+
// instead of thrown as a failed request.
2291+
const route = this.getRoute('metadata');
2292+
const res = await this.fetch(
2293+
`${this.baseUrl}${route}/app/${encodeURIComponent(appId)}/bundle`,
2294+
cacheOptions?.ifNoneMatch ? { headers: { 'If-None-Match': cacheOptions.ifNoneMatch } } : {},
2295+
{ admitNotModified: true },
2296+
);
2297+
const header = res.headers.get('ETag');
2298+
const etag = header ? { value: header.replace(/^W\/|"/g, ''), weak: header.startsWith('W/') } : undefined;
2299+
if (res.status === 304) return { notModified: true, ...(etag ? { etag } : {}) };
2300+
const data = await this.unwrapResponse<AppMetadataBundleResponse>(res);
2301+
return { notModified: false, data, ...(etag ? { etag } : {}) };
2302+
},
2303+
22652304
/**
22662305
* ADR-0046 §6: resolve a book spine against the docs that exist now.
22672306
* An unknown name is treated as a package id (implicit per-package book).
@@ -7599,7 +7638,11 @@ export class ObjectStackClient {
75997638
this.token = token;
76007639
}
76017640

7602-
private async fetch(url: string, options: RequestInit = {}): Promise<Response> {
7641+
/**
7642+
* `transport.admitNotModified` — [#27627] a conditional read's `304` is its
7643+
* answer, not a failure: returned to the caller instead of thrown.
7644+
*/
7645+
private async fetch(url: string, options: RequestInit = {}, transport: { admitNotModified?: boolean } = {}): Promise<Response> {
76037646
this.logger.debug('HTTP request', {
76047647
method: options.method || 'GET',
76057648
url,
@@ -7635,7 +7678,7 @@ export class ObjectStackClient {
76357678
ok: res.ok
76367679
});
76377680

7638-
if (!res.ok) {
7681+
if (!res.ok && !(transport.admitNotModified && res.status === 304)) {
76397682
let errorBody: any;
76407683
try {
76417684
errorBody = await res.json();
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27627] `meta.getAppBundle` — the app metadata bundle read, conditional in
5+
* `getCached`'s spelling: `ifNoneMatch` in; `notModified`, `data` and
6+
* `etag: { value, weak }` out, with the server's `304` answered rather than
7+
* thrown as a failed request. `getCached` itself rides the same opt-in: its `304`
8+
* branch was unreachable while the transport threw on every non-ok status.
9+
*/
10+
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { ObjectStackClient } from './index';
13+
14+
const BUNDLE = {
15+
app: { type: 'app', name: 'crm', item: { name: 'crm' } },
16+
objects: [{ type: 'object', name: 'account', item: { name: 'account' } }],
17+
views: [],
18+
pages: [],
19+
dashboards: [],
20+
};
21+
const ETAG = `"${'a'.repeat(64)}"`;
22+
23+
function clientAnswering(status: number, body: unknown, etag?: string) {
24+
const fetchMock = vi.fn().mockResolvedValue({
25+
ok: status >= 200 && status < 300,
26+
status,
27+
statusText: status === 200 ? 'OK' : status === 304 ? 'Not Modified' : 'Error',
28+
json: async () => body,
29+
headers: new Headers(etag ? { ETag: etag } : {}),
30+
});
31+
return { client: new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }), fetchMock };
32+
}
33+
34+
describe('[#27627] meta.getAppBundle', () => {
35+
it('reads GET /meta/app/{appId}/bundle and answers the bundle with its validator', async () => {
36+
const { client, fetchMock } = clientAnswering(200, BUNDLE, ETAG);
37+
const answer = await client.meta.getAppBundle('crm app');
38+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/app/crm%20app/bundle');
39+
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBeNull();
40+
expect(answer).toEqual({ notModified: false, data: BUNDLE, etag: { value: 'a'.repeat(64), weak: false } });
41+
});
42+
43+
it('sends ifNoneMatch, and a 304 is answered `notModified` instead of thrown', async () => {
44+
const { client, fetchMock } = clientAnswering(304, undefined, ETAG);
45+
const answer = await client.meta.getAppBundle('crm', { ifNoneMatch: ETAG });
46+
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG);
47+
expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } });
48+
});
49+
50+
it('a refusal still rejects with the server\'s code and status', async () => {
51+
const { client } = clientAnswering(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'No.' } });
52+
const err: any = await client.meta.getAppBundle('payroll').then(
53+
() => { throw new Error('expected the read to reject'); },
54+
(e) => e,
55+
);
56+
expect(err.httpStatus).toBe(403);
57+
expect(err.code).toBe('PERMISSION_DENIED');
58+
});
59+
});
60+
61+
describe('[#27627] meta.getCached answers a 304 instead of throwing it', () => {
62+
it('sends ifNoneMatch and answers `notModified` with the validator it sent', async () => {
63+
const { client, fetchMock } = clientAnswering(304, undefined);
64+
const answer = await client.meta.getCached('account', { ifNoneMatch: ETAG });
65+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/object/account');
66+
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG);
67+
expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } });
68+
});
69+
70+
it('a refusal still rejects', async () => {
71+
const { client } = clientAnswering(404, { success: false, error: { code: 'NOT_FOUND', message: 'No.' } });
72+
const err: any = await client.meta.getCached('ghost').then(
73+
() => { throw new Error('expected the read to reject'); },
74+
(e) => e,
75+
);
76+
expect(err.httpStatus).toBe(404);
77+
});
78+
});

0 commit comments

Comments
 (0)