diff --git a/.changeset/27627-console-app-metadata-bundle.md b/.changeset/27627-console-app-metadata-bundle.md new file mode 100644 index 00000000000..70ea2e0d062 --- /dev/null +++ b/.changeset/27627-console-app-metadata-bundle.md @@ -0,0 +1,16 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +--- + +feat(console): entering an app loads its metadata bundle once, and a record open reads no metadata + +Clause-②: yes (widening) +Metadata-protocol: none + +Entering an app now loads that app's metadata bundle (`GET /api/v1/meta/app/{appId}/bundle`) in one request, through the new `ObjectStackAdapter.loadAppBundle(appId)`. The bundle holds the app, the objects its navigation reaches together with the objects that reference them, and their views, pages and dashboards. Re-entering the app sends `If-None-Match` with the held `ETag` exactly as served, and a `304` keeps the bundle the console already holds. The request goes through the client's conditional `meta.getAppBundle(appId, { ifNoneMatch })`. A bundle served `no-store` with no `ETag` answers the reads that waited on it and is not kept. + +- **Reads of bundled items.** A by-name metadata read of an item the bundle holds is answered from the bundle, as that item's own envelope, and never goes to its by-name route. This covers `getObjectSchema`, `getView`, `getPage`, `getApp`, `getItems`, and every caller of the client's `meta.getItem(type, name)`, including app-shell's `MetadataProvider`. A record open in a warm session therefore sends its page request and no metadata request. A cold open adds at most the one bundle request, sent alongside the page request. +- **Reads of everything else.** Items outside the app (Studio's reads, a lookup to `sys_user`, an item the caller may not read) keep their by-name routes. A read with options does too. An item withheld from the caller is absent from the bundle and carries no name there, so its by-name read gives that item's own refusal. +- **Staying current.** A metadata write through any of the client's write doors, or a host invalidation that names a bundled item, makes the next bundled read revalidate the bundle first. An answer older than the adapter cache's lifetime is revalidated the same way. A cache clear, which happens on an organization, session or locale switch, downloads the bundle again with no validator. +- **No bundle.** If the bundle is refused, the app is unknown, or the server has no such route, every read goes to its by-name route as before. diff --git a/content/docs/frontend/data-objectstack.mdx b/content/docs/frontend/data-objectstack.mdx index 9797cc522f5..141832cfd70 100644 --- a/content/docs/frontend/data-objectstack.mdx +++ b/content/docs/frontend/data-objectstack.mdx @@ -234,12 +234,13 @@ either form): - `connect()` - Establish the connection (called lazily by every operation) - `getCacheStats()` / `invalidateCache(key?)` / `clearCache()` - Cache control +- `loadAppBundle(appId)` - Load the app's metadata bundle (`GET /meta/app/{appId}/bundle`), or revalidate the one held with `If-None-Match`; by-name metadata reads of the items it holds are answered from it (see [App metadata bundle](#app-metadata-bundle)) - `getClient()` - Access the underlying `@objectstack/client` instance - `getConnectionState()` / `isConnected()` - Connection introspection - `onConnectionStateChange(listener)` - Subscribe to state changes (returns unsubscribe) - `onBatchProgress(listener)` - Subscribe to bulk progress (returns unsubscribe) -Those six bullets cover nine members, and they are the documented subset. +Those seven bullets cover ten members, and they are the documented subset. `ObjectStackAdapter` declares more members beyond `DataSource` than these — `Exclude, keyof DataSource>` lists them all; the rest are lower-level seams — client discovery, cache-key @@ -247,6 +248,34 @@ invalidation, dataset and dashboard access and advisory subscriptions among them — which this page does not document and which carry no compatibility promise here. +### App metadata bundle + +The console calls `loadAppBundle(appName)` when it enters an app. One request, +`GET /api/v1/meta/app/{appId}/bundle`, returns the app, the objects its +navigation reaches with the objects that reference them, and their views, +pages and dashboards. Each one is the envelope its own +`GET /meta/{type}/{name}` read would return to this caller. + +- A by-name read of an item the bundle holds is answered from the bundle and + never sent: `getObjectSchema`, `getView`, `getPage`, `getApp`, `getItems`, + and the client's `meta.getItem(type, name)` with no options. +- An item the bundle does not hold keeps its by-name read. That covers objects + outside the app, reads made with options, and an item withheld from the + caller, which is absent from the bundle and gets its own read's refusal. +- Calling `loadAppBundle` again for the same app sends `If-None-Match` with the + held `ETag` exactly as served (a weak `W/` validator included), and a `304` + keeps the bundle already held. +- A bundle served `no-store` with no `ETag` (the server could not settle a member's field + visibility) answers the reads that waited on it and is not kept. Later reads + go by name until the app is entered again. +- A metadata write through the client, or an `invalidateCache(key)` that names + a bundled item, makes the next bundled read revalidate the bundle first. So + does an answer older than the cache TTL. `clearCache()` drops every bundle, + and the next read downloads it again. + +If the bundle is refused, or the server has no such route, every read stays on +its by-name route. + ### Per-element data binding A schema node's `dataSource` key is the spec's `ElementDataSource` (validated by diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 71cfaa1b3b8..79aa0fdb6d1 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -387,6 +387,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // (objectui#11818). const appSegment = appAddressSegment(apps, matchedApp) ?? appName ?? appRouteSegment(activeApp); + // objectstack#27627 — entering an app loads its metadata bundle, and + // re-entering it revalidates the one held (`loadAppBundle`): from then on the + // by-name metadata reads of everything the app reaches are answered from it. + // Only the app the URL names: a pseudo-route's fallback app is not entered. + // A host adapter without the bundle keeps every by-name read. + const bundleAppName: string | undefined = typeof matchedApp?.name === 'string' ? matchedApp.name : undefined; + useEffect(() => { + const load = dataSource?.loadAppBundle; + if (!bundleAppName || typeof load !== 'function') return; + void load.call(dataSource, bundleAppName); + }, [dataSource, bundleAppName]); + // objectstack-ai/objectstack#26183 — a URL that reached the app through its // NAME alias moves to the app's one address, path, search, hash and history // state kept. The alias still opens (old bookmarks and shared links keep diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx index a0094f025fa..51ea0bf0af4 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx @@ -212,6 +212,9 @@ function makeAdapter() { if (url.includes('/api/v1/discovery')) { return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } }); } + // objectstack#27627 — entering an app loads its metadata bundle; that is + // not the by-name read this file asks about, and it answers as refused. + if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY); const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url); if (item) { metaItemRequests.push(url); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.loadAppBundle-27627.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.loadAppBundle-27627.test.tsx new file mode 100644 index 00000000000..eb7fd8131ae --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.loadAppBundle-27627.test.tsx @@ -0,0 +1,142 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27627 — entering an app loads its metadata bundle, by the app's + * NAME (the address `GET /meta/app/:name/bundle` takes), on the package-id + * route the sidebar builds and on the name alias alike; staying in the app + * asks it once. + * + * Measured from a real `AppContent` mount (the + * `AppContent.recentOnPackageRoute-11818` harness); the adapter's + * `loadAppBundle` is the observation point. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ModalForm: () => null, +})); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); +vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +const { loadAppBundle } = vi.hoisted(() => ({ loadAppBundle: vi.fn(async (_appId: string) => {}) })); +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', + loadAppBundle, +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** The showcase app as the catalog serves it: a name AND a package id. */ +const APPS = [ + { + name: 'showcase_app', + label: 'Showcase', + _packageId: 'com.example.showcase', + isDefault: true, + navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }], + }, +]; + +const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }]; + +// One value for the whole run: the route's effects key on these members, and +// a fresh value per render would re-run them without end. +const METADATA = { + apps: APPS, + objects: OBJECTS, + loading: false, + ensureType: undefined, + error: null, + refresh: async () => {}, +}; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => METADATA, +})); + +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ACTION_RUNNER, + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +beforeEach(() => { + loadAppBundle.mockClear(); +}); +afterEach(cleanup); + +async function visit(path: string) { + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + const view = ( + + + + } /> + + + + ); + const mounted = render(view); + await screen.findByTestId('object-view', undefined, { timeout: 5000 }); + return { rerender: () => mounted.rerender(view) }; +} + +describe('AppContent loads the entered app\'s metadata bundle (objectstack#27627)', () => { + it('on the package-id route, by the app name, once', async () => { + const { rerender } = await visit('/apps/com.example.showcase/showcase_task'); + rerender(); + expect(loadAppBundle.mock.calls).toEqual([['showcase_app']]); + }); + + it('on the name alias, by the same name', async () => { + await visit('/apps/showcase_app/showcase_task'); + expect(loadAppBundle.mock.calls.map(([app]) => app)).toEqual(expect.arrayContaining(['showcase_app'])); + expect(new Set(loadAppBundle.mock.calls.map(([app]) => app))).toEqual(new Set(['showcase_app'])); + }); +}); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx index 88717c91ff7..5c9fe97c2fd 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx @@ -147,6 +147,9 @@ function makeAdapter() { if (url.includes('/api/v1/discovery')) { return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } }); } + // objectstack#27627 — entering an app loads its metadata bundle; that is + // not the by-name read this file asks about, and it answers as refused. + if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY); const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url); if (item) { metaItemRequests.push(url); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx index 5c7e95efb5c..37f2dd8d053 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx @@ -163,6 +163,9 @@ function makeAdapter() { if (url.includes('/api/v1/discovery')) { return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } }); } + // objectstack#27627 — entering an app loads its metadata bundle; that is + // not the by-name read this file asks about, and it answers as refused. + if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY); const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url); if (item) { metaItemRequests.push(url); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx index 586eb003e62..60fc3541855 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx @@ -27,6 +27,11 @@ * the page request, in the same wave: the record's comment thread, its * activity and, once per view, the mention list. None of them is a part of the page the payload answers; * they are pinned on their own below, not counted as page requests. + * + * And once the app's metadata bundle is in (`loadAppBundle`, on app entry), the + * object definitions the blocks read come from it: a record open in a warm + * session asks the page and no metadata at all (its 动态 stream's own reads aside), and a cold one adds at most + * the one bundle request. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -89,7 +94,8 @@ const TOTAL: Record = { os_27627_task: 7, os_27627_case: 4, os_2 interface Held { wave: number; method: string; path: string; body: any; released: boolean; release: () => void } -const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +const json = (body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json', ...headers } }); /** The page payload the route answers for `body`, for record `id`. */ function pageAnswer(id: string, body: any) { @@ -112,7 +118,17 @@ function pageAnswer(id: string, body: any) { }; } +const APP = 'demo'; +const BUNDLE = `/api/v1/meta/app/${APP}/bundle`; + +/** The bundle carries its per-caller validator, as the route serves a bundle it may be kept by. */ +const BUNDLE_HEADERS = { ETag: 'W/"demo-bundle-1"', 'Cache-Control': 'private, no-cache' }; + function answerFor(h: Held): unknown { + if (h.path === BUNDLE) { + const env = (type: string, item: { name: string }) => ({ type, name: item.name, item }); + return { app: env('app', { name: APP }), objects: ALL.map((o) => env('object', o)), views: [], pages: [], dashboards: [] }; + } const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path); if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }; const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path); @@ -136,7 +152,7 @@ function heldTransport() { wave: state.wave, method, path, body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, released: false, - release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h))); }, + release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h), h.path === BUNDLE ? BUNDLE_HEADERS : {})); }, }; held.push(h); }); @@ -222,9 +238,9 @@ describe('objectstack#27627 — one request opens a record page', () => { const data = dataRequests(held); expect(data.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); expect(held.filter((h) => /[?&](\$?top)=1\b/.test(h.path))).toEqual([]); - // Every DATA request goes out in the first wave. The object definitions the - // blocks read (`/meta/object/…`) are the app bundle's to serve once per - // session; until then they are read as before, beside it. + // Every DATA request goes out in the first wave. With no app entered there + // is no metadata bundle, so the object definitions the blocks read + // (`/meta/object/…`) are read by name beside it; with one, see below. expect(data.every((h) => h.wave === 1)).toBe(true); // The request names what the page draws: its four related lists, the @@ -327,6 +343,35 @@ describe('objectstack#27627 — one request opens a record page', () => { expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1']); }); + it('a cold session, entering the app as the record opens, adds one bundle request and no by-name metadata read', async () => { + const { held, state, open, ds } = await boot(); + void ds.loadAppBundle(APP); + open('c1'); + await runWaves(held, state); + expect(held.filter(isMeta).map((h) => `${h.method} ${h.path}`)).toEqual([`GET ${BUNDLE}`]); + expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + // Both go out in the first wave, beside the 动态 stream's own reads: nothing waits on the other. + expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']); + expect(held.every((h) => h.wave === 1)).toBe(true); + }); + + it('a warm session opens a record with one page request and zero metadata requests', async () => { + const { held, state, open, ds } = await boot(); + // The app was entered earlier in the session; no record was opened yet, + // so no object definition was ever read by name. + const entered = ds.loadAppBundle(APP); + await runWaves(held, state); + await entered; + held.length = 0; + state.wave = 1; + open('c1'); + await runWaves(held, state); + expect(held.filter(isMeta)).toEqual([]); + expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + // What else goes out is the 动态 stream's own data reads (objectstack#27524), no metadata. + expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']); + }); + it('in a drawer, an adapter without the payload keeps the count warm-up: each of the next record\'s counts is asked once', async () => { const { held, state, open, ds } = await boot(); Object.assign(ds, { getRecordPage: undefined, prefetchRecordPage: undefined }); diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index abde40e1bb1..34c3909f7a8 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -447,6 +447,12 @@ dataSource.invalidateCache(); // Invalidate all cached schemas dataSource.clearCache(); ``` +### App metadata bundle + +`loadAppBundle(appId)` loads an app's metadata bundle (`GET /api/v1/meta/app/{appId}/bundle`) in one request. The console calls it when it enters an app. A by-name read of an item the bundle holds is then answered from the bundle and never sent: `getObjectSchema`, `getView`, `getPage`, `getApp`, `getItems` and `getClient().meta.getItem(type, name)`. Items the bundle does not hold keep their by-name reads. + +Calling `loadAppBundle` again for the same app sends `If-None-Match` with the held `ETag` exactly as served (a weak `W/` validator included), and a `304` keeps what is held. A bundle served `no-store` with no `ETag` answers the reads that waited on it and is not kept. A metadata write through the client, or an `invalidateCache(key)` that names a bundled item, makes the next bundled read revalidate the bundle first. `clearCache()` drops the bundles. + ### Cache Configuration - **LRU Eviction**: Automatically evicts least recently used entries when cache is full diff --git a/ui/packages/data-objectstack/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts new file mode 100644 index 00000000000..89d1d079f90 --- /dev/null +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -0,0 +1,216 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The app metadata bundle (objectstack#27627): one read, when the console + * enters an app, answers the by-name metadata reads of everything that app + * reaches — the app, the objects its navigation and their inbound relations + * reach, and their views, pages and dashboards. + * + * The bundle is the caller's own: the server builds every part through the + * same per-caller read gate as the by-name read it replaces, and an item the + * caller cannot read is simply absent from it. So: + * + * - an item the bundle HOLDS is answered from it, and never by a by-name + * read (no dual path for a bundled item); + * - an item it does NOT hold is the by-name route's to answer, refusal + * included (Studio, objects outside the app, an item withheld from the + * caller); + * - re-entering an app, or reading past the adapter's cache lifetime, asks + * again with `If-None-Match`, the validator sent back byte-for-byte: a + * `304` keeps what is held; + * - a bundle served `no-store` answers the reads that waited on it and is + * not kept: later reads go by name until the app is entered again; + * - a metadata write the adapter sees, or an invalidation a host asks for, + * marks the bundles stale, and the next bundled read asks again first. + * + * Nothing here knows the transport: the adapter hands in one function that + * asks the server and reports what it answered. + */ + +import type { AppMetadataBundleResponse } from '@objectstack/spec/api'; + +/** One bundled item. */ +export interface AppBundleEntry { + /** The item's document (the envelope's `item`). */ + item: unknown; + /** The whole envelope its by-name read answers (`type`, `name`, `item`, an object's `sortability`, …). */ + envelope: Record; +} + +/** The bundle's items, by metadata type then name. */ +export type AppBundleItems = Map>; + +/** What one bundle request obtained. */ +export type AppBundleAnswer = + /** + * A `200`: the bundle, and its validator when the server sent one. + * `noStore`: served `no-store`, so it answers the reads waiting on it and is not kept. + */ + | { status: 'fresh'; etag?: string; items: AppBundleItems; noStore?: boolean } + /** A `304`: what is held is current. */ + | { status: 'unchanged' } + /** No bundle for this app: refused, unknown, or no such route on this server. */ + | { status: 'absent' }; + +export type AppBundleRequest = (appId: string, etag: string | undefined) => Promise; + +interface AppBundleState { + appId: string; + etag?: string; + items: AppBundleItems; + /** When the server last answered for this bundle (a `200` or a `304`). */ + checkedAt: number; + stale: boolean; + /** The load on the wire; it settles with the items its answer gives the reads waiting on it. */ + inflight: Promise | null; +} + +/** + * The bundles one adapter has loaded, one per app entered this session; the + * last app entered is the one bundled reads are answered from. The others are + * kept so re-entering one revalidates instead of downloading it again. + */ +export class AppBundleStore { + private readonly bundles = new Map(); + private active: string | null = null; + + constructor( + private readonly request: AppBundleRequest, + /** How long an answer stands before a bundled read asks again (the adapter cache's lifetime). */ + private readonly ttlMs: number, + ) {} + + /** + * Enter `appId`: load its bundle, or revalidate the one held. Joins a load + * already in flight. Never rejects — a bundle that could not be had leaves + * every read to its by-name route. + */ + enter(appId: string): Promise { + if (!appId) return Promise.resolve(); + this.active = appId; + const state = this.bundles.get(appId); + return (state?.inflight ?? this.ask(appId)).then(() => undefined); + } + + /** + * The bundled answer for `type` / `name`, or `undefined` when the active + * bundle does not hold it (or no app was entered). Waits for a load in + * flight, and asks again first when the bundle is stale or past its lifetime. + */ + async read(type: string, name: string): Promise { + const appId = this.active; + if (!appId) return undefined; + const state = this.bundles.get(appId); + if (!state) return undefined; + let items = state.items; + if (state.inflight) items = await state.inflight; + else if (state.stale || Date.now() - state.checkedAt >= this.ttlMs) items = await this.ask(appId); + return items.get(type)?.get(name); + } + + /** Whether any bundle held this session holds `type` / `name`. */ + holds(type: string, name: string): boolean { + for (const state of this.bundles.values()) { + if (state.items.get(type)?.has(name)) return true; + } + return false; + } + + /** Whether any bundle held this session holds an item of `type`. */ + holdsType(type: string): boolean { + for (const state of this.bundles.values()) { + if ((state.items.get(type)?.size ?? 0) > 0) return true; + } + return false; + } + + /** Every held bundle asks again before it answers its next read. */ + markStale(): void { + for (const state of this.bundles.values()) state.stale = true; + } + + /** + * Forget every bundle's items and validator, keeping the apps entered: the + * world they were read in is gone (an organization, session or locale + * switch), so the next bundled read downloads the active bundle again. + */ + clear(): void { + for (const appId of Array.from(this.bundles.keys())) { + this.bundles.set(appId, { appId, items: new Map(), checkedAt: 0, stale: true, inflight: null }); + } + } + + private ask(appId: string): Promise { + const held = this.bundles.get(appId); + const state: AppBundleState = held ?? { appId, items: new Map(), checkedAt: 0, stale: true, inflight: null }; + this.bundles.set(appId, state); + const inflight = this.request(appId, state.items.size > 0 ? state.etag : undefined) + .catch((): AppBundleAnswer => ({ status: 'absent' })) + .then((answer) => { + // A `clear()` while this was out replaced the state: this answer + // belongs to the world it left, so it is dropped. + const current = this.bundles.get(appId); + if (current !== state) return current?.items ?? new Map(); + state.inflight = null; + state.checkedAt = Date.now(); + state.stale = false; + if (answer.status === 'fresh' && answer.noStore) { + state.items = new Map(); + state.etag = undefined; + return answer.items; + } + if (answer.status === 'fresh') { + state.items = answer.items; + state.etag = answer.etag; + } else if (answer.status === 'absent') { + state.items = new Map(); + state.etag = undefined; + } + return state.items; + }); + state.inflight = inflight; + return inflight; + } +} + +/** Where the bundle lists each metadata type. */ +const BUNDLE_LISTS: ReadonlyArray = [ + ['objects', 'object'], + ['views', 'view'], + ['pages', 'page'], + ['dashboards', 'dashboard'], +]; + +/** + * Index the bundle's answer (`AppMetadataBundleResponseSchema`, `@objectstack/spec/api`) + * by type and name. The answer is bare, as the `/meta` item reads answer: the + * app under `app`, and the other items listed under `objects`, `views`, + * `pages` and `dashboards`. Every member is the envelope its own + * `GET /meta/{type}/{name}` answers (`GetMetaItemResponse`), so it is kept + * whole and handed back as that read's answer. + */ +export function indexAppBundle(body: AppMetadataBundleResponse): AppBundleItems { + const items: AppBundleItems = new Map(); + if (!body || typeof body !== 'object') return items; + const bag = body as unknown as Record; + const put = (type: string, member: unknown) => { + if (!member || typeof member !== 'object') return; + const envelope = member as Record; + if (typeof envelope.name !== 'string' || !envelope.name) return; + let byName = items.get(type); + if (!byName) items.set(type, (byName = new Map())); + byName.set(envelope.name, { item: envelope.item, envelope }); + }; + put('app', bag.app); + for (const [key, type] of BUNDLE_LISTS) { + const list = bag[key]; + if (Array.isArray(list)) for (const member of list) put(type, member); + } + return items; +} diff --git a/ui/packages/data-objectstack/src/appBundle-27627.test.ts b/ui/packages/data-objectstack/src/appBundle-27627.test.ts new file mode 100644 index 00000000000..045d562cc67 --- /dev/null +++ b/ui/packages/data-objectstack/src/appBundle-27627.test.ts @@ -0,0 +1,302 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27627 — the console loads an app's metadata bundle once, and + * answers the by-name reads of the items it holds from it. + * + * Measured through the REAL adapter and the REAL client over a transport that + * holds every request open until the test answers it, so a read made while the + * bundle is on the wire is genuinely made before it lands. + * + * Pinned: + * - a read made while the bundle loads waits for it, and a bundled item is + * never asked by name (objects, views, pages, dashboards, the app); + * - an item the bundle does not hold, and a call with options, keep the + * by-name route; + * - re-entering sends `If-None-Match` (a weak validator byte-for-byte), and a + * `304` keeps what is held; + * - an invalidation naming a bundled item, and any metadata write through the + * client, make the next bundled read ask the bundle again first; + * - a bundled object carries its envelope's `sortability`, as its by-name read does; + * - a cache clear downloads it again; + * - a bundle served `no-store` answers the reads that waited on it and is not kept; + * - a refused bundle leaves every read to its by-name route. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readObjectSortability } from '@object-ui/core'; +import { ObjectStackAdapter } from './index'; + +const BASE = 'http://test.local'; +const APP = 'b27627_app'; +const BUNDLE_PATH = `/api/v1/meta/app/${APP}/bundle`; + +interface HeldRequest { + method: string; + path: string; + headers: Record; + respond: (status: number, body?: unknown, headers?: Record) => void; +} + +function respondWith(status: number, body: unknown, headers: Record = {}): Response { + if (status === 304) return new Response(null, { status, headers }); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +async function heldAdapter() { + const held: HeldRequest[] = []; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + if (url.endsWith('/api/v1/discovery')) { + return Promise.resolve(respondWith(200, { success: true, data: { capabilities: {}, routes: {} } })); + } + const headers: Record = {}; + new Headers(init?.headers ?? {}).forEach((v, k) => { headers[k] = v; }); + return new Promise((resolve) => { + held.push({ + method, + path: decodeURIComponent(url.replace(BASE, '')), + headers, + respond: (status, body, h) => resolve(respondWith(status, body, h)), + }); + }); + }); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + return { ds, held }; +} + +async function drain() { + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); +} + +const contact = { name: 'b27627_contact', label: 'Contact', fields: { name: { type: 'text', label: 'Name' } } }; +const task = { name: 'b27627_task', label: 'Task', fields: { contact: { type: 'lookup', reference: 'b27627_contact' } } }; + +/** The bundle's answer: bare, every member the envelope its by-name read answers. */ +function bundle(label = 'Contact') { + const env = (type: string, item: { name: string; [key: string]: unknown }, extra: Record = {}) => ({ type, name: item.name, item, ...extra }); + return { + app: env('app', { name: APP, label: 'App' }), + objects: [ + env('object', { ...contact, label }, { sortability: { fields: { name: { sortable: true } } } }), + env('object', task), + ], + views: [env('view', { name: 'b27627_contact.all', object: 'b27627_contact', label: 'All' })], + pages: [env('page', { name: 'b27627_contact_page', label: 'Contact page' })], + dashboards: [env('dashboard', { name: 'b27627_home', label: 'Home' })], + }; +} + +const metaRequests = (held: HeldRequest[]) => held.filter((h) => h.path.startsWith('/api/v1/meta/')); + +describe('objectstack#27627 — the app metadata bundle', () => { + it('a read made while the bundle loads waits for it, and nothing it holds is asked by name', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + const reads = Promise.all([ + ds.getObjectSchema('b27627_contact'), + ds.getObjectSchema('b27627_task'), + ds.getView('b27627_contact', 'b27627_contact.all'), + ds.getPage('b27627_contact_page'), + ds.getApp(APP), + ds.getClient().meta.getItem('dashboard', 'b27627_home'), + ]); + await drain(); + expect(held.map((h) => `${h.method} ${h.path}`)).toEqual([`GET ${BUNDLE_PATH}`]); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await entered; + const [schema, child, view, page, app, dashboard] = await reads; + expect((schema as any).label).toBe('Contact'); + expect((child as any).name).toBe('b27627_task'); + expect((view as any).label).toBe('All'); + expect((page as any).label).toBe('Contact page'); + expect((app as any).label).toBe('App'); + expect((dashboard as any).item.label).toBe('Home'); + await drain(); + expect(held).toHaveLength(1); + }); + + it('an item the bundle does not hold, and a read with options, keep the by-name route', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await entered; + + const outside = ds.getObjectSchema('sys_user'); + const scoped = ds.getClient().meta.getItem('page', 'b27627_contact_page', { packageId: 'p1' }); + await drain(); + const byPath = (path: string) => held.find((h) => h.path === path)!; + expect(held.slice(1).map((h) => h.path).sort()).toEqual([ + '/api/v1/meta/object/sys_user', + '/api/v1/meta/page/b27627_contact_page?package=p1', + ]); + byPath('/api/v1/meta/object/sys_user').respond(200, { success: true, data: { item: { name: 'sys_user', label: 'User' } } }); + byPath('/api/v1/meta/page/b27627_contact_page?package=p1').respond(200, { success: true, data: { item: { name: 'b27627_contact_page' } } }); + expect((await outside as any).label).toBe('User'); + await scoped; + }); + + it('re-entering sends If-None-Match, and a 304 keeps what is held', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + expect(held[0].headers['if-none-match']).toBeUndefined(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await first; + + const again = ds.loadAppBundle(APP); + await drain(); + expect(held).toHaveLength(2); + expect(held[1].path).toBe(BUNDLE_PATH); + expect(held[1].headers['if-none-match']).toBe('W/"b1"'); + held[1].respond(304); + await again; + + expect((await ds.getObjectSchema('b27627_contact') as any).label).toBe('Contact'); + await drain(); + expect(held).toHaveLength(2); + }); + + it('an invalidation naming a bundled item asks the bundle again, not the item by name', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await first; + expect((await ds.getObjectSchema('b27627_contact') as any).label).toBe('Contact'); + + ds.invalidateCache('b27627_contact'); + const reread = ds.getObjectSchema('b27627_contact'); + await drain(); + expect(metaRequests(held).map((h) => h.path)).toEqual([BUNDLE_PATH, BUNDLE_PATH]); + expect(held[1].headers['if-none-match']).toBe('W/"b1"'); + held[1].respond(200, bundle('Person'), { ETag: 'W/"b2"' }); + expect((await reread as any).label).toBe('Person'); + }); + + it('an invalidation naming nothing the bundle holds leaves it current', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await first; + ds.invalidateCache('view-overrides:b27627_contact'); + ds.invalidateCache('sys_user'); + await ds.getView('b27627_contact', 'b27627_contact.all'); + await drain(); + expect(held).toHaveLength(1); + }); + + it('a metadata write through the client makes the next bundled read ask the bundle again', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await first; + + const save = ds.getClient().meta.saveItem('page', 'b27627_contact_page', { name: 'b27627_contact_page', label: 'Renamed' }); + await drain(); + held[1].respond(200, { success: true, data: { name: 'b27627_contact_page' } }); + await save; + + const reread = ds.getClient().meta.getItem('page', 'b27627_contact_page'); + await drain(); + expect(held[2].path).toBe(BUNDLE_PATH); + expect(held[2].headers['if-none-match']).toBe('W/"b1"'); + const next = bundle(); + next.pages = [{ type: 'page', name: 'b27627_contact_page', item: { name: 'b27627_contact_page', label: 'Renamed' } }]; + held[2].respond(200, next, { ETag: 'W/"b2"' }); + expect((await reread as any).item.label).toBe('Renamed'); + }); + + it('a cache clear downloads the bundle again, with no validator', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await first; + + ds.clearCache(); + const reread = ds.getObjectSchema('b27627_contact'); + await drain(); + expect(metaRequests(held).map((h) => h.path)).toEqual([BUNDLE_PATH, BUNDLE_PATH]); + expect(held[1].headers['if-none-match']).toBeUndefined(); + held[1].respond(200, bundle('Kontakt'), { ETag: 'W/"b3"' }); + expect((await reread as any).label).toBe('Kontakt'); + }); + + it("a bundled object carries its envelope's sortability, as its by-name read does", async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: '"b1"' }); + await entered; + expect(readObjectSortability(await ds.getObjectSchema('b27627_contact'))?.fields.name?.sortable).toBe(true); + }); + + it('a weak validator goes back byte-for-byte, its `W/` prefix included', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"sha256-Zm9vYmFy"' }); + await first; + const again = ds.loadAppBundle(APP); + await drain(); + expect(held[1].headers['if-none-match']).toBe('W/"sha256-Zm9vYmFy"'); + held[1].respond(304); + await again; + }); + + it('a bundle served `no-store` answers the reads that waited on it, and is not kept', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + const waiting = ds.getObjectSchema('b27627_contact'); + await drain(); + // A member under an undetermined posture: no validator, not to be cached. + held[0].respond(200, bundle(), { 'Cache-Control': 'private, no-store' }); + await first; + expect((await waiting as any).label).toBe('Contact'); + expect(held).toHaveLength(1); + + // Nothing was kept: the next read of a member goes by name. + const later = ds.getClient().meta.getItem('object', 'b27627_task'); + await drain(); + expect(held[1].path).toBe('/api/v1/meta/object/b27627_task'); + held[1].respond(200, { success: true, data: { type: 'object', name: 'b27627_task', item: task } }); + await later; + + // Re-entering downloads it again, with no validator. + const again = ds.loadAppBundle(APP); + await drain(); + expect(held[2].path).toBe(BUNDLE_PATH); + expect(held[2].headers['if-none-match']).toBeUndefined(); + held[2].respond(200, bundle(), { 'Cache-Control': 'private, no-store' }); + await again; + }); + + it('a refused bundle leaves every read to its by-name route', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + // An app the caller cannot read refuses the whole bundle with the app read's own answer. + held[0].respond(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'Not your app' } }); + await first; + + const read = ds.getObjectSchema('b27627_contact'); + await drain(); + expect(held[1].path).toBe('/api/v1/meta/object/b27627_contact'); + held[1].respond(200, { success: true, data: { item: contact } }); + expect((await read as any).name).toBe('b27627_contact'); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 9c6974d2b75..12bac76a0f0 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -87,6 +87,7 @@ import { type DatasetDrillRange, } from '@object-ui/core'; import { MetadataCache } from './cache/MetadataCache'; +import { AppBundleStore, indexAppBundle, type AppBundleAnswer } from './app-bundle'; import { MetadataClient } from './metadata-client'; import { formatMetadataError } from './metadata-error'; import { @@ -937,6 +938,22 @@ export function serializeOrderBy(orderby: QueryParams['$orderby']): string | und */ const VIEW_LIST_KEY = 'list:view'; +/** + * Whether a key handed to {@link ObjectStackAdapter.invalidateCache} names an + * item an app bundle holds (objectstack#27627), in every spelling the cache's + * keys and its host callers use: a bare object name (this adapter's object + * key), `view:OBJECT:NAME`, `TYPE:NAME` (app-shell `MetadataService`), and a + * type's list key (`list:TYPE`, {@link VIEW_LIST_KEY}), which names every + * item of that type. + */ +function cacheKeyNamesBundledItem(key: string, bundles: AppBundleStore): boolean { + if (!key.includes(':')) return bundles.holds('object', key); + const [head, ...rest] = key.split(':'); + if (head === 'list') return bundles.holdsType(rest.join(':')); + if (head === 'view' && rest.length >= 2) return bundles.holds('view', rest.slice(1).join(':')); + return bundles.holds(head, rest.join(':')); +} + const discoveryCache = new Map>(); /** @@ -3341,6 +3358,10 @@ export class ObjectStackAdapter implements DataSource { private connected: boolean = false; private connectPromise: Promise | null = null; private metadataCache: MetadataCache; + // objectstack#27627 — the app metadata bundles this session entered + // (`loadAppBundle`); a by-name metadata read of an item the active one holds + // is answered from it (`fetchObjectSchemaFresh`, `installBundledItemReads`). + private appBundles: AppBundleStore; private connectionState: ConnectionState = 'disconnected'; private connectionStateListeners: ConnectionStateListener[] = []; private batchProgressListeners: BatchProgressListener[] = []; @@ -3485,6 +3506,13 @@ export class ObjectStackAdapter implements DataSource { this.metadataCache = new MetadataCache(config.cache); // objectui#13044 — after the cache, which the wrap reads. this.installViewListShare(); + // The bundle's answers stand as long as the cache's entries do (the + // cache's own default lifetime when none is configured). + this.appBundles = new AppBundleStore( + (appId, etag) => this.requestAppBundle(appId, etag), + config.cache?.ttl || 5 * 60 * 1000, + ); + this.installBundledItemReads(); this.autoReconnect = config.autoReconnect ?? true; this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3; this.reconnectDelay = config.reconnectDelay ?? 1000; @@ -4465,6 +4493,75 @@ export class ObjectStackAdapter implements DataSource { } } + /** + * Answer this adapter's client's by-name metadata reads of bundled items + * from the app bundle (objectstack#27627). + * + * `client.meta.getItem(type, name)` is the one door every published by-name + * read here goes through — {@link getView}, {@link getPage}, {@link getApp}, + * {@link getItems}, {@link probeMetaItemAccess}, and app-shell's + * `MetadataProvider` through {@link getClient} — so an item the active + * bundle holds is answered there, as the route's envelope, and never asked + * by name. An item it does not hold, and any call with options, goes to the + * route as before. Objects are answered the same way in + * {@link fetchObjectSchemaFresh}, which reads the route without the SDK. + * + * A write through any of the client's metadata write doors marks the + * bundles stale once it settles, whatever it wrote: the next bundled read + * asks the bundle again first, so it is never answered from before the + * write. Same wrap shape and bound as {@link installViewListShare}. + */ + private installBundledItemReads(): void { + const meta = this.client.meta; + const original = meta.getItem.bind(meta); + meta.getItem = ((...args: Parameters) => { + const [type, name, options] = args; + if (options !== undefined) return original(...args); + return this.appBundles.read(type, name).then((entry) => + entry ? (structuredClone(entry.envelope) as Awaited>) : original(type, name), + ); + }) as typeof meta.getItem; + + for (const door of ['saveItem', 'deleteItem', 'publishItem', 'rollbackItem'] as const) { + const write = meta[door] as ((...args: any[]) => Promise) | undefined; + if (typeof write !== 'function') continue; + const bound = write.bind(meta); + (meta as any)[door] = (...args: any[]) => bound(...args).finally(() => this.appBundles.markStale()); + } + } + + /** + * Enter an app: load its metadata bundle, or revalidate the one this + * session already holds with `If-None-Match` (objectstack#27627). From then + * on, a by-name read of an item the bundle holds — an object definition, a + * view, a page, a dashboard, the app — is answered from it; anything else + * keeps its by-name route. + * + * Never rejects: an app with no bundle (refused, unknown, a server without + * the route, a failed request) leaves every read to its by-name route. + * + * @param appId - the app's name, as `GET /meta/app/:name` addresses it + */ + loadAppBundle(appId: string): Promise { + return this.appBundles.enter(appId); + } + + /** The one round trip behind {@link loadAppBundle}. */ + private async requestAppBundle(appId: string, etag: string | undefined): Promise { + await this.connect(); + // The client's conditional read: a `304` is answered as `notModified`, + // any other refusal rejects (the store reads that as no bundle). + const answer = await this.client.meta.getAppBundle(appId, etag ? { ifNoneMatch: etag } : undefined); + if (answer.notModified) return { status: 'unchanged' }; + const items = indexAppBundle(answer.data); + // A bundle holding a member whose field visibility the server could not + // settle is served `no-store` with no validator: it answers the reads + // that waited on it and is not kept. + if (!answer.etag) return { status: 'fresh', items, noStore: true }; + // The validator goes back as the server spelled it, a weak `W/` included. + return { status: 'fresh', etag: `${answer.etag.weak ? 'W/' : ''}"${answer.etag.value}"`, items }; + } + /** * The published view list for this adapter's own readers * ({@link listViews}, {@link listViewOverrides}): the answer stored under @@ -5576,34 +5673,14 @@ export class ObjectStackAdapter implements DataSource { * still gets a cheap `304`. We go through `fetchImpl` (the adapter's * authenticated fetch) rather than `client.meta.getItem` because the SDK does * not expose the request cache mode. + * + * An object the active app bundle holds is answered from it instead, as the + * route's envelope, and never asked by name (objectstack#27627, + * {@link loadAppBundle}). */ private async fetchObjectSchemaFresh(objectName: string): Promise { - const url = `${this.baseUrl}/api/v1/meta/object/${encodeURIComponent(objectName)}`; - - const headers: Record = { 'Content-Type': 'application/json' }; - // Bearer (server-to-server) callers configure `this.token`; cookie/console - // auth is injected by `fetchImpl` (the authenticated fetch wrapper). - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - - const res = await this.fetchImpl(url, { - method: 'GET', - headers, - // Revalidate instead of serving the stale `max-age` body (see doc above). - cache: 'no-cache', - }); - - if (!res.ok) { - const errBody: any = await res.json().catch(() => ({ message: res.statusText })); - const envelope = readErrorEnvelope(errBody); - const err: any = new Error(envelope.message ?? res.statusText); - err.status = res.status; - err.code = envelope.code; - throw err; - } - - const body: any = await res.json(); + const bundled = await this.appBundles.read('object', objectName); + const body: any = bundled ? structuredClone(bundled.envelope) : await this.requestObjectSchema(objectName); // Unwrap defensively across server/SDK response shapes: the standard // `{ success, data }` envelope, an `{ item }` wrapper, or the bare item. const data = body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body; @@ -5634,6 +5711,35 @@ export class ObjectStackAdapter implements DataSource { return attachObjectSortability(item, data && typeof data === 'object' ? (data as any).sortability : undefined); } + /** The by-name route read behind {@link fetchObjectSchemaFresh}: its response body. */ + private async requestObjectSchema(objectName: string): Promise { + const url = `${this.baseUrl}/api/v1/meta/object/${encodeURIComponent(objectName)}`; + + const headers: Record = { 'Content-Type': 'application/json' }; + // Bearer (server-to-server) callers configure `this.token`; cookie/console + // auth is injected by `fetchImpl` (the authenticated fetch wrapper). + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const res = await this.fetchImpl(url, { + method: 'GET', + headers, + // Revalidate instead of serving the stale `max-age` body (see {@link fetchObjectSchemaFresh}). + cache: 'no-cache', + }); + + if (!res.ok) { + const errBody: any = await res.json().catch(() => ({ message: res.statusText })); + const envelope = readErrorEnvelope(errBody); + const err: any = new Error(envelope.message ?? res.statusText); + err.status = res.status; + err.code = envelope.code; + throw err; + } + return res.json(); + } + /** * List every registered object (code- and DB-defined) from the metadata * registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }` @@ -5785,6 +5891,7 @@ export class ObjectStackAdapter implements DataSource { // read asked after this write is answered neither by the stored list nor // by a list request sent before it. this.metadataCache.invalidate?.(VIEW_LIST_KEY); + if (this.appBundles.holds('view', viewName)) this.appBundles.markStale(); } /** @@ -7233,6 +7340,10 @@ export class ObjectStackAdapter implements DataSource { */ invalidateCache(key?: string): void { this.metadataCache.invalidate(key); + // objectstack#27627 — and the bundles, when the key names an item one of + // them holds: its next read must reach the server, so the bundle asks + // again (`If-None-Match`) before answering it. + if (key === undefined || cacheKeyNamesBundledItem(key, this.appBundles)) this.appBundles.markStale(); } /** @@ -7240,6 +7351,9 @@ export class ObjectStackAdapter implements DataSource { */ clearCache(): void { this.metadataCache.clear(); + // objectstack#27627 — the world the bundles were read in is gone (an + // organization, session or locale switch). + this.appBundles.clear(); } /**