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
20 changes: 20 additions & 0 deletions .changeset/27627-app-metadata-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': minor
'@objectstack/rest': minor
'@objectstack/client': minor
---

feat(rest): `GET /api/v1/meta/app/{appId}/bundle` answers an app's metadata in one request, per caller, with an ETag (#27627)

Clause-②: yes (widening: a new route and its response shape; no existing route, key or answer changes)
Metadata-protocol: added

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`):

- **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.
- **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.
- **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`.
- **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).
- The query-parameter set is closed and empty.
- `@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.
- `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.
198 changes: 198 additions & 0 deletions content/docs/references/api/app-bundle.mdx

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion content/docs/references/api/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: API Protocol — complete schema reference
navTitle: API Protocol
description: "The ObjectStack API Protocol in 33 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---

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

<Cards>
<Card href="/docs/references/api/analytics" title="Analytics" description="Source: packages/spec/src/api/analytics.zod.ts" />
<Card href="/docs/references/api/app-bundle" title="App Bundle" description="Source: packages/spec/src/api/app-bundle.zod.ts" />
<Card href="/docs/references/api/auth" title="Auth" description="Source: packages/spec/src/api/auth.zod.ts" />
<Card href="/docs/references/api/auth-endpoints" title="Auth Endpoints" description="Source: packages/spec/src/api/auth-endpoints.zod.ts" />
<Card href="/docs/references/api/automation-api" title="Automation Api" description="Source: packages/spec/src/api/automation-api.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"plugin-rest-api",
"storage",
"---More---",
"app-bundle",
"error-code-ledger",
"misc",
"package-api-assembled",
Expand Down
9 changes: 5 additions & 4 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1571 schemas across 14 protocol modules
---

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

---

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

## API Protocol

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

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

| File | Schemas |
| :--- | :--- |
| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` |
| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` |
| [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` |
| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` |
| [`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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 460 |
| `api/` | 461 |
49 changes: 46 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GetRecordFieldAccessResponse,
RecordPageRequest,
RecordPageResponse,
AppMetadataBundleResponse,
UpdateManyRequest,
DeleteManyRequest,
BatchOptions,
Expand Down Expand Up @@ -252,6 +253,15 @@ export interface ClientConfig {
*/
export type DiscoveryResult = GetDiscoveryResponse;

/**
* [#27627] `meta.getAppBundle`'s answer: the bundle and its validator, or
* `notModified` when the `ifNoneMatch` sent still names what the server would
* serve this caller.
*/
export type AppBundleCacheResponse =
| { notModified: false; data: AppMetadataBundleResponse; etag?: { value: string; weak: boolean } }
| { notModified: true; etag?: { value: string; weak: boolean } };

/**
* @deprecated Use `data.query()` with standard QueryAST parameters instead.
* This interface uses legacy parameter names (filter/sort/top/skip) that
Expand Down Expand Up @@ -2092,9 +2102,11 @@ export class ObjectStackClient {
headers['If-Modified-Since'] = cacheOptions.ifModifiedSince;
}

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

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

/**
* [#27627] The app metadata bundle: the app and the objects, views, pages
* and dashboards its navigation reaches, each as its own
* `GET /meta/{type}/{name}` answers it to this caller, in one request
* (`GET /meta/app/{appId}/bundle`). An item the caller may not read is
* absent; an app the caller may not read rejects with the app read's own
* answer. The response carries a per-caller `ETag` under
* `Cache-Control: private, no-cache`, so the platform's HTTP cache
* revalidates it with `If-None-Match`.
*/
getAppBundle: async (appId: string, cacheOptions?: { ifNoneMatch?: string }): Promise<AppBundleCacheResponse> => {
// The conditional spelling is `getCached`'s (`MetadataCacheRequest.ifNoneMatch` in,
// `notModified` + `etag: { value, weak }` out), with the 304 admitted by the transport
// instead of thrown as a failed request.
const route = this.getRoute('metadata');
const res = await this.fetch(
`${this.baseUrl}${route}/app/${encodeURIComponent(appId)}/bundle`,
cacheOptions?.ifNoneMatch ? { headers: { 'If-None-Match': cacheOptions.ifNoneMatch } } : {},
{ admitNotModified: true },
);
const header = res.headers.get('ETag');
const etag = header ? { value: header.replace(/^W\/|"/g, ''), weak: header.startsWith('W/') } : undefined;
if (res.status === 304) return { notModified: true, ...(etag ? { etag } : {}) };
const data = await this.unwrapResponse<AppMetadataBundleResponse>(res);
return { notModified: false, data, ...(etag ? { etag } : {}) };
},

/**
* ADR-0046 §6: resolve a book spine against the docs that exist now.
* An unknown name is treated as a package id (implicit per-package book).
Expand Down Expand Up @@ -7599,7 +7638,11 @@ export class ObjectStackClient {
this.token = token;
}

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

if (!res.ok) {
if (!res.ok && !(transport.admitNotModified && res.status === 304)) {
let errorBody: any;
try {
errorBody = await res.json();
Expand Down
78 changes: 78 additions & 0 deletions packages/client/src/meta-app-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27627] `meta.getAppBundle` — the app metadata bundle read, conditional in
* `getCached`'s spelling: `ifNoneMatch` in; `notModified`, `data` and
* `etag: { value, weak }` out, with the server's `304` answered rather than
* thrown as a failed request. `getCached` itself rides the same opt-in: its `304`
* branch was unreachable while the transport threw on every non-ok status.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackClient } from './index';

const BUNDLE = {
app: { type: 'app', name: 'crm', item: { name: 'crm' } },
objects: [{ type: 'object', name: 'account', item: { name: 'account' } }],
views: [],
pages: [],
dashboards: [],
};
const ETAG = `"${'a'.repeat(64)}"`;

function clientAnswering(status: number, body: unknown, etag?: string) {
const fetchMock = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : status === 304 ? 'Not Modified' : 'Error',
json: async () => body,
headers: new Headers(etag ? { ETag: etag } : {}),
});
return { client: new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }), fetchMock };
}

describe('[#27627] meta.getAppBundle', () => {
it('reads GET /meta/app/{appId}/bundle and answers the bundle with its validator', async () => {
const { client, fetchMock } = clientAnswering(200, BUNDLE, ETAG);
const answer = await client.meta.getAppBundle('crm app');
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/app/crm%20app/bundle');
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBeNull();
expect(answer).toEqual({ notModified: false, data: BUNDLE, etag: { value: 'a'.repeat(64), weak: false } });
});

it('sends ifNoneMatch, and a 304 is answered `notModified` instead of thrown', async () => {
const { client, fetchMock } = clientAnswering(304, undefined, ETAG);
const answer = await client.meta.getAppBundle('crm', { ifNoneMatch: ETAG });
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG);
expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } });
});

it('a refusal still rejects with the server\'s code and status', async () => {
const { client } = clientAnswering(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'No.' } });
const err: any = await client.meta.getAppBundle('payroll').then(
() => { throw new Error('expected the read to reject'); },
(e) => e,
);
expect(err.httpStatus).toBe(403);
expect(err.code).toBe('PERMISSION_DENIED');
});
});

describe('[#27627] meta.getCached answers a 304 instead of throwing it', () => {
it('sends ifNoneMatch and answers `notModified` with the validator it sent', async () => {
const { client, fetchMock } = clientAnswering(304, undefined);
const answer = await client.meta.getCached('account', { ifNoneMatch: ETAG });
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/object/account');
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG);
expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } });
});

it('a refusal still rejects', async () => {
const { client } = clientAnswering(404, { success: false, error: { code: 'NOT_FOUND', message: 'No.' } });
const err: any = await client.meta.getCached('ghost').then(
() => { throw new Error('expected the read to reject'); },
(e) => e,
);
expect(err.httpStatus).toBe(404);
});
});
Loading
Loading