From bc9c79e0bee5c6bf7b7334a8dbc69a751bfcd191 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:10:05 +0000 Subject: [PATCH 1/7] feat(data-objectstack): answer bundled by-name metadata reads from the app bundle loadAppBundle(appId) reads GET /meta/app/{appId}/bundle once per app entry and revalidates with If-None-Match; an object, view, page, dashboard or app the active bundle holds is answered from it and never asked by name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .../data-objectstack/src/app-bundle.ts | 209 +++++++++++++++ .../src/appBundle-27627.test.ts | 249 ++++++++++++++++++ ui/packages/data-objectstack/src/index.ts | 166 ++++++++++-- 3 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 ui/packages/data-objectstack/src/app-bundle.ts create mode 100644 ui/packages/data-objectstack/src/appBundle-27627.test.ts 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..f40cea6092a --- /dev/null +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -0,0 +1,209 @@ +/** + * 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`: a `304` keeps what is held; + * - 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. + */ + +/** One bundled item, as the by-name route's envelope would carry it. */ +export interface AppBundleEntry { + /** The item's document. */ + item: unknown; + /** The envelope keys the by-name route serves beside `item` (an object's `sortability`, `disabledActions`). */ + 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. */ + | { status: 'fresh'; etag?: string; items: AppBundleItems } + /** 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; + 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); + if (state?.inflight) return state.inflight; + return this.ask(appId); + } + + /** + * 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; + let state = this.bundles.get(appId); + if (!state) return undefined; + if (state.inflight) await state.inflight; + else if (state.stale || Date.now() - state.checkedAt >= this.ttlMs) await this.ask(appId); + state = this.bundles.get(appId); + return state?.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. + if (this.bundles.get(appId) !== state) return; + state.inflight = null; + state.checkedAt = Date.now(); + state.stale = false; + if (answer.status === 'fresh') { + state.items = answer.items; + state.etag = answer.etag; + } else if (answer.status === 'absent') { + state.items = new Map(); + state.etag = undefined; + } + }); + state.inflight = inflight; + return inflight; + } +} + +/** Where a bundle answer lists each metadata type. */ +const BUNDLE_LISTS: ReadonlyArray = [ + ['objects', 'object'], + ['views', 'view'], + ['pages', 'page'], + ['dashboards', 'dashboard'], +]; + +/** + * Index a bundle answer's data by type and name. Each listed part is either the + * by-name route's envelope (`{ item, … }`) or the bare document; the app rides + * under `app`. + */ +export function indexAppBundle(data: unknown): AppBundleItems { + const items: AppBundleItems = new Map(); + if (!data || typeof data !== 'object') return items; + const bag = data as Record; + const put = (type: string, part: unknown) => { + const entry = toEntry(part); + if (!entry) return; + const name = (entry.item as { name?: unknown }).name; + if (typeof name !== 'string' || !name) return; + let byName = items.get(type); + if (!byName) items.set(type, (byName = new Map())); + byName.set(name, entry); + }; + if (bag.app !== undefined) put('app', bag.app); + for (const [key, type] of BUNDLE_LISTS) { + const list = bag[key]; + if (Array.isArray(list)) for (const part of list) put(type, part); + } + return items; +} + +function toEntry(part: unknown): AppBundleEntry | undefined { + if (!part || typeof part !== 'object') return undefined; + const p = part as Record; + if ('item' in p) { + if (!p.item || typeof p.item !== 'object') return undefined; + return { item: p.item, envelope: p }; + } + return { item: p, envelope: { item: p } }; +} 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..9e313536664 --- /dev/null +++ b/ui/packages/data-objectstack/src/appBundle-27627.test.ts @@ -0,0 +1,249 @@ +/** + * 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`, 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 cache clear downloads the bundle again, with no validator; + * - a refused bundle leaves every read to its by-name route. + */ +import { describe, it, expect, vi } from 'vitest'; +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' } } }; + +function bundle(label = 'Contact') { + return { + success: true, + data: { + app: { name: APP, label: 'App' }, + objects: [ + { item: { ...contact, label }, sortability: { name: { sortable: true } } }, + { item: task }, + ], + views: [{ name: 'b27627_contact.all', object: 'b27627_contact', label: 'All' }], + pages: [{ name: 'b27627_contact_page', label: 'Contact page' }], + dashboards: [{ 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.data.pages = [{ 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 refused bundle leaves every read to its by-name route', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'No such 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 8ba471489a6..09da6dbff97 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>(); /** @@ -3322,6 +3339,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[] = []; @@ -3466,6 +3487,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; @@ -4442,6 +4470,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 ? ({ type, name, ...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(); + const url = `${this.baseUrl}/api/v1/meta/app/${encodeURIComponent(appId)}/bundle`; + const headers: Record = { 'Content-Type': 'application/json', ...this.getAuthHeaders() }; + if (etag) headers['If-None-Match'] = etag; + // `no-store`: the validator is this adapter's to send, so the browser's + // HTTP cache neither answers for the server nor hides its `304`. + const res = await this.fetchImpl(url, { method: 'GET', headers, cache: 'no-store' }); + if (res.status === 304) return { status: 'unchanged' }; + if (!res.ok) return { status: 'absent' }; + const body: any = await res.json(); + const data = body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body; + return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items: indexAppBundle(data) }; + } + /** * The published view list for this adapter's own readers * ({@link listViews}, {@link listViewOverrides}): the answer stored under @@ -5553,34 +5650,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; @@ -5611,6 +5688,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 }` @@ -5762,6 +5868,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(); } /** @@ -7210,6 +7317,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(); } /** @@ -7217,6 +7328,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(); } /** From 3ab501696ed5209d87b6708856dff4e2e48b7148 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:13:40 +0000 Subject: [PATCH 2/7] feat(app-shell): entering an app loads its metadata bundle; record open reads no metadata The bundle's members are read as the by-name envelopes the spec's AppMetadataBundleResponse declares; acceptance pins a warm record open at one page request and zero metadata requests, a cold one at one bundle request. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .../app-shell/src/console/AppContent.tsx | 11 ++++ ...etailView.recordPagePayload-27627.test.tsx | 43 +++++++++++++-- .../data-objectstack/src/app-bundle.ts | 48 +++++++---------- .../src/appBundle-27627.test.ts | 53 ++++++++++++++----- ui/packages/data-objectstack/src/index.ts | 6 +-- 5 files changed, 112 insertions(+), 49 deletions(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 693001002b1..5325a9abb9e 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -387,6 +387,17 @@ 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. + // A host adapter without the bundle keeps every by-name read. + const bundleAppName: string | undefined = typeof activeApp?.name === 'string' ? activeApp.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/views/RecordDetailView.recordPagePayload-27627.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx index b90d5b46e70..164bd95a5aa 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 @@ -21,6 +21,11 @@ * approval / lock / record read, one wave of data requests; the badges draw the * payload's totals; and the record a drawer step shows next is asked ahead, so stepping to it * asks nothing for the record it opens. + * + * 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, 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'; @@ -106,7 +111,14 @@ function pageAnswer(id: string, body: any) { }; } +const APP = 'demo'; +const BUNDLE = `/api/v1/meta/app/${APP}/bundle`; + 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); @@ -206,9 +218,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 @@ -301,6 +313,31 @@ describe('objectstack#27627 — one request opens a record page', () => { expect(dataRequests(held).filter((h) => !isPage(h))).toEqual([]); }); + 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: nothing waits on the other. + 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.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + }); + 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/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts index f40cea6092a..2eb94602104 100644 --- a/ui/packages/data-objectstack/src/app-bundle.ts +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -30,11 +30,11 @@ * asks the server and reports what it answered. */ -/** One bundled item, as the by-name route's envelope would carry it. */ +/** One bundled item. */ export interface AppBundleEntry { - /** The item's document. */ + /** The item's document (the envelope's `item`). */ item: unknown; - /** The envelope keys the by-name route serves beside `item` (an object's `sortability`, `disabledActions`). */ + /** The whole envelope its by-name read answers (`type`, `name`, `item`, an object's `sortability`, …). */ envelope: Record; } @@ -164,7 +164,7 @@ export class AppBundleStore { } } -/** Where a bundle answer lists each metadata type. */ +/** Where the bundle lists each metadata type. */ const BUNDLE_LISTS: ReadonlyArray = [ ['objects', 'object'], ['views', 'view'], @@ -173,37 +173,29 @@ const BUNDLE_LISTS: ReadonlyArray = [ ]; /** - * Index a bundle answer's data by type and name. Each listed part is either the - * by-name route's envelope (`{ item, … }`) or the bare document; the app rides - * under `app`. + * Index the bundle's answer (`AppMetadataBundleResponse`, `@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(data: unknown): AppBundleItems { +export function indexAppBundle(body: unknown): AppBundleItems { const items: AppBundleItems = new Map(); - if (!data || typeof data !== 'object') return items; - const bag = data as Record; - const put = (type: string, part: unknown) => { - const entry = toEntry(part); - if (!entry) return; - const name = (entry.item as { name?: unknown }).name; - if (typeof name !== 'string' || !name) return; + if (!body || typeof body !== 'object') return items; + const bag = body 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(name, entry); + byName.set(envelope.name, { item: envelope.item, envelope }); }; - if (bag.app !== undefined) put('app', bag.app); + put('app', bag.app); for (const [key, type] of BUNDLE_LISTS) { const list = bag[key]; - if (Array.isArray(list)) for (const part of list) put(type, part); + if (Array.isArray(list)) for (const member of list) put(type, member); } return items; } - -function toEntry(part: unknown): AppBundleEntry | undefined { - if (!part || typeof part !== 'object') return undefined; - const p = part as Record; - if ('item' in p) { - if (!p.item || typeof p.item !== 'object') return undefined; - return { item: p.item, envelope: p }; - } - return { item: p, envelope: { item: p } }; -} diff --git a/ui/packages/data-objectstack/src/appBundle-27627.test.ts b/ui/packages/data-objectstack/src/appBundle-27627.test.ts index 9e313536664..ade89661e9b 100644 --- a/ui/packages/data-objectstack/src/appBundle-27627.test.ts +++ b/ui/packages/data-objectstack/src/appBundle-27627.test.ts @@ -22,10 +22,12 @@ * - re-entering sends `If-None-Match`, 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 cache clear downloads the bundle again, with no validator; + * - a bundled object carries its envelope's `sortability`, as its by-name read does; + * - a cache clear, or a bundle served with no validator, downloads it again; * - 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'; @@ -78,19 +80,18 @@ async function drain() { 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 }, extra: Record = {}) => ({ type, name: item.name, item, ...extra }); return { - success: true, - data: { - app: { name: APP, label: 'App' }, - objects: [ - { item: { ...contact, label }, sortability: { name: { sortable: true } } }, - { item: task }, - ], - views: [{ name: 'b27627_contact.all', object: 'b27627_contact', label: 'All' }], - pages: [{ name: 'b27627_contact_page', label: 'Contact page' }], - dashboards: [{ name: 'b27627_home', label: 'Home' }], - }, + 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' } as { name: string })], + pages: [env('page', { name: 'b27627_contact_page', label: 'Contact page' } as { name: string })], + dashboards: [env('dashboard', { name: 'b27627_home', label: 'Home' } as { name: string })], }; } @@ -212,7 +213,7 @@ describe('objectstack#27627 — the app metadata bundle', () => { expect(held[2].path).toBe(BUNDLE_PATH); expect(held[2].headers['if-none-match']).toBe('W/"b1"'); const next = bundle(); - next.data.pages = [{ name: 'b27627_contact_page', label: 'Renamed' }]; + 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'); }); @@ -233,11 +234,35 @@ describe('objectstack#27627 — the app metadata bundle', () => { 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 bundle served with no validator (`no-store`) is downloaded again, never revalidated', async () => { + const { ds, held } = await heldAdapter(); + const first = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { 'Cache-Control': 'private, no-store' }); + await first; + const again = ds.loadAppBundle(APP); + await drain(); + expect(held[1].path).toBe(BUNDLE_PATH); + expect(held[1].headers['if-none-match']).toBeUndefined(); + held[1].respond(200, bundle()); + 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(); - held[0].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'No such app' } }); + // 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'); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 09da6dbff97..b24e7bf9bca 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -4495,7 +4495,7 @@ export class ObjectStackAdapter implements DataSource { const [type, name, options] = args; if (options !== undefined) return original(...args); return this.appBundles.read(type, name).then((entry) => - entry ? ({ type, name, ...structuredClone(entry.envelope) } as Awaited>) : original(type, name), + entry ? (structuredClone(entry.envelope) as Awaited>) : original(type, name), ); }) as typeof meta.getItem; @@ -4534,9 +4534,7 @@ export class ObjectStackAdapter implements DataSource { const res = await this.fetchImpl(url, { method: 'GET', headers, cache: 'no-store' }); if (res.status === 304) return { status: 'unchanged' }; if (!res.ok) return { status: 'absent' }; - const body: any = await res.json(); - const data = body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body; - return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items: indexAppBundle(data) }; + return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items: indexAppBundle(await res.json()) }; } /** From 1f8915d5b3d8638327b94c1028f326719fdef88e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:15:55 +0000 Subject: [PATCH 3/7] docs(data-objectstack): the app metadata bundle; app-shell pins the app entry load Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .../27627-console-app-metadata-bundle.md | 16 ++ content/docs/frontend/data-objectstack.mdx | 27 +++- .../AppContent.loadAppBundle-27627.test.tsx | 142 ++++++++++++++++++ ui/packages/data-objectstack/README.md | 6 + 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/27627-console-app-metadata-bundle.md create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.loadAppBundle-27627.test.tsx diff --git a/.changeset/27627-console-app-metadata-bundle.md b/.changeset/27627-console-app-metadata-bundle.md new file mode 100644 index 00000000000..a138a20bd2d --- /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-②: no +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`, and a `304` keeps the bundle the console already holds. + +- **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..05f6e4defc4 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,30 @@ 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`, and a + `304` keeps the bundle already held. +- 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/__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/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index abde40e1bb1..cae4d402428 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`, and a `304` keeps what is held. 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 From c6e775cf5b508d2f3a6e4cd878eada5584c8a7a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:03:54 +0000 Subject: [PATCH 4/7] fix(app-shell): load the bundle only for the app the URL names; verdict transports answer the bundle Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- ui/packages/app-shell/src/console/AppContent.tsx | 3 ++- .../__tests__/AppContent.deniedVsUnpublished.test.tsx | 3 +++ .../AppContent.settingsMarketplaceVerdict-27507.test.tsx | 3 +++ .../AppContent.setupItemRoutesVerdict-12706.test.tsx | 3 +++ ui/packages/data-objectstack/src/appBundle-27627.test.ts | 8 ++++---- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 5325a9abb9e..5e13b5aac1e 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -390,8 +390,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // 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 activeApp?.name === 'string' ? activeApp.name : undefined; + const bundleAppName: string | undefined = typeof matchedApp?.name === 'string' ? matchedApp.name : undefined; useEffect(() => { const load = dataSource?.loadAppBundle; if (!bundleAppName || typeof load !== 'function') return; 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.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/data-objectstack/src/appBundle-27627.test.ts b/ui/packages/data-objectstack/src/appBundle-27627.test.ts index ade89661e9b..b80969871b5 100644 --- a/ui/packages/data-objectstack/src/appBundle-27627.test.ts +++ b/ui/packages/data-objectstack/src/appBundle-27627.test.ts @@ -82,16 +82,16 @@ const task = { name: 'b27627_task', label: 'Task', fields: { contact: { type: 'l /** 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 }, extra: Record = {}) => ({ type, name: item.name, item, ...extra }); + 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' } as { name: string })], - pages: [env('page', { name: 'b27627_contact_page', label: 'Contact page' } as { name: string })], - dashboards: [env('dashboard', { name: 'b27627_home', label: 'Home' } as { name: string })], + 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' })], }; } From 19983743fe4bf4047cf08904d34e1981ea9dc605 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:48:35 +0000 Subject: [PATCH 5/7] fix(data-objectstack): a no-store bundle is not kept; the weak validator goes back as served Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .../27627-console-app-metadata-bundle.md | 2 +- content/docs/frontend/data-objectstack.mdx | 8 +++- ui/packages/data-objectstack/README.md | 2 +- .../data-objectstack/src/app-bundle.ts | 39 ++++++++++++------ .../src/appBundle-27627.test.ts | 40 ++++++++++++++++--- ui/packages/data-objectstack/src/index.ts | 8 +++- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/.changeset/27627-console-app-metadata-bundle.md b/.changeset/27627-console-app-metadata-bundle.md index a138a20bd2d..168292cb8fc 100644 --- a/.changeset/27627-console-app-metadata-bundle.md +++ b/.changeset/27627-console-app-metadata-bundle.md @@ -8,7 +8,7 @@ feat(console): entering an app loads its metadata bundle once, and a record open Clause-②: no 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`, and a `304` keeps the bundle the console already holds. +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. A bundle served `no-store` 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. diff --git a/content/docs/frontend/data-objectstack.mdx b/content/docs/frontend/data-objectstack.mdx index 05f6e4defc4..1ef3ba0d0c4 100644 --- a/content/docs/frontend/data-objectstack.mdx +++ b/content/docs/frontend/data-objectstack.mdx @@ -262,8 +262,12 @@ pages and dashboards. Each one is the envelope its own - 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`, and a - `304` keeps the bundle already held. +- 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` (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, diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index cae4d402428..f8688d3627a 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -451,7 +451,7 @@ dataSource.clearCache(); `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`, and a `304` keeps what is held. 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. +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` 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 diff --git a/ui/packages/data-objectstack/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts index 2eb94602104..d22bb4fd49f 100644 --- a/ui/packages/data-objectstack/src/app-bundle.ts +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -22,7 +22,10 @@ * 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`: a `304` keeps what is held; + * 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. * @@ -43,8 +46,11 @@ export type AppBundleItems = Map>; /** What one bundle request obtained. */ export type AppBundleAnswer = - /** A `200`: the bundle, and its validator when the server sent one. */ - | { status: 'fresh'; etag?: string; items: AppBundleItems } + /** + * 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. */ @@ -59,7 +65,8 @@ interface AppBundleState { /** When the server last answered for this bundle (a `200` or a `304`). */ checkedAt: number; stale: boolean; - inflight: Promise | null; + /** The load on the wire; it settles with the items its answer gives the reads waiting on it. */ + inflight: Promise | null; } /** @@ -86,8 +93,7 @@ export class AppBundleStore { if (!appId) return Promise.resolve(); this.active = appId; const state = this.bundles.get(appId); - if (state?.inflight) return state.inflight; - return this.ask(appId); + return (state?.inflight ?? this.ask(appId)).then(() => undefined); } /** @@ -98,12 +104,12 @@ export class AppBundleStore { async read(type: string, name: string): Promise { const appId = this.active; if (!appId) return undefined; - let state = this.bundles.get(appId); + const state = this.bundles.get(appId); if (!state) return undefined; - if (state.inflight) await state.inflight; - else if (state.stale || Date.now() - state.checkedAt >= this.ttlMs) await this.ask(appId); - state = this.bundles.get(appId); - return state?.items.get(type)?.get(name); + 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`. */ @@ -138,7 +144,7 @@ export class AppBundleStore { } } - private ask(appId: string): Promise { + 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); @@ -147,10 +153,16 @@ export class AppBundleStore { .then((answer) => { // A `clear()` while this was out replaced the state: this answer // belongs to the world it left, so it is dropped. - if (this.bundles.get(appId) !== state) return; + 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; @@ -158,6 +170,7 @@ export class AppBundleStore { state.items = new Map(); state.etag = undefined; } + return state.items; }); state.inflight = inflight; return inflight; diff --git a/ui/packages/data-objectstack/src/appBundle-27627.test.ts b/ui/packages/data-objectstack/src/appBundle-27627.test.ts index b80969871b5..045d562cc67 100644 --- a/ui/packages/data-objectstack/src/appBundle-27627.test.ts +++ b/ui/packages/data-objectstack/src/appBundle-27627.test.ts @@ -19,11 +19,13 @@ * 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`, and a `304` keeps what is held; + * - 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, or a bundle served with no validator, downloads it again; + * - 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'; @@ -243,17 +245,43 @@ describe('objectstack#27627 — the app metadata bundle', () => { expect(readObjectSortability(await ds.getObjectSchema('b27627_contact'))?.fields.name?.sortable).toBe(true); }); - it('a bundle served with no validator (`no-store`) is downloaded again, never revalidated', async () => { + 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[1].path).toBe(BUNDLE_PATH); - expect(held[1].headers['if-none-match']).toBeUndefined(); - held[1].respond(200, bundle()); + 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; }); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index b24e7bf9bca..d5548f5b924 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -4534,7 +4534,13 @@ export class ObjectStackAdapter implements DataSource { const res = await this.fetchImpl(url, { method: 'GET', headers, cache: 'no-store' }); if (res.status === 304) return { status: 'unchanged' }; if (!res.ok) return { status: 'absent' }; - return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items: indexAppBundle(await res.json()) }; + const items = indexAppBundle(await res.json()); + // 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 (/\bno-store\b/i.test(res.headers.get('cache-control') ?? '')) return { status: 'fresh', items, noStore: true }; + // The validator goes back byte-for-byte, a weak `W/` prefix included. + return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items }; } /** From 5ba1e156f52789c6418ef87878b276a753fccb35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 10:14:33 +0000 Subject: [PATCH 6/7] feat(data-objectstack): the bundle goes through the client's conditional getAppBundle, typed by the spec's bundle response Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .../27627-console-app-metadata-bundle.md | 2 +- content/docs/frontend/data-objectstack.mdx | 2 +- ...etailView.recordPagePayload-27627.test.tsx | 8 +++++-- ui/packages/data-objectstack/README.md | 2 +- .../data-objectstack/src/app-bundle.ts | 8 ++++--- ui/packages/data-objectstack/src/index.ts | 22 ++++++++----------- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.changeset/27627-console-app-metadata-bundle.md b/.changeset/27627-console-app-metadata-bundle.md index 168292cb8fc..771c4307f26 100644 --- a/.changeset/27627-console-app-metadata-bundle.md +++ b/.changeset/27627-console-app-metadata-bundle.md @@ -8,7 +8,7 @@ feat(console): entering an app loads its metadata bundle once, and a record open Clause-②: no 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. A bundle served `no-store` answers the reads that waited on it and is not kept. +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. diff --git a/content/docs/frontend/data-objectstack.mdx b/content/docs/frontend/data-objectstack.mdx index 1ef3ba0d0c4..141832cfd70 100644 --- a/content/docs/frontend/data-objectstack.mdx +++ b/content/docs/frontend/data-objectstack.mdx @@ -265,7 +265,7 @@ pages and dashboards. Each one is the envelope its own - 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` (the server could not settle a member's field +- 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 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 1925a32c199..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 @@ -94,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) { @@ -120,6 +121,9 @@ 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 }); @@ -148,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); }); diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index f8688d3627a..34c3909f7a8 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -451,7 +451,7 @@ dataSource.clearCache(); `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` 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. +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 diff --git a/ui/packages/data-objectstack/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts index d22bb4fd49f..89d1d079f90 100644 --- a/ui/packages/data-objectstack/src/app-bundle.ts +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -33,6 +33,8 @@ * 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`). */ @@ -186,17 +188,17 @@ const BUNDLE_LISTS: ReadonlyArray = [ ]; /** - * Index the bundle's answer (`AppMetadataBundleResponse`, `@objectstack/spec/api`) + * 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: unknown): AppBundleItems { +export function indexAppBundle(body: AppMetadataBundleResponse): AppBundleItems { const items: AppBundleItems = new Map(); if (!body || typeof body !== 'object') return items; - const bag = body as Record; + const bag = body as unknown as Record; const put = (type: string, member: unknown) => { if (!member || typeof member !== 'object') return; const envelope = member as Record; diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index ef2674b8769..12bac76a0f0 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -4549,21 +4549,17 @@ export class ObjectStackAdapter implements DataSource { /** The one round trip behind {@link loadAppBundle}. */ private async requestAppBundle(appId: string, etag: string | undefined): Promise { await this.connect(); - const url = `${this.baseUrl}/api/v1/meta/app/${encodeURIComponent(appId)}/bundle`; - const headers: Record = { 'Content-Type': 'application/json', ...this.getAuthHeaders() }; - if (etag) headers['If-None-Match'] = etag; - // `no-store`: the validator is this adapter's to send, so the browser's - // HTTP cache neither answers for the server nor hides its `304`. - const res = await this.fetchImpl(url, { method: 'GET', headers, cache: 'no-store' }); - if (res.status === 304) return { status: 'unchanged' }; - if (!res.ok) return { status: 'absent' }; - const items = indexAppBundle(await res.json()); + // 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 + // settle is served `no-store` with no validator: it answers the reads // that waited on it and is not kept. - if (/\bno-store\b/i.test(res.headers.get('cache-control') ?? '')) return { status: 'fresh', items, noStore: true }; - // The validator goes back byte-for-byte, a weak `W/` prefix included. - return { status: 'fresh', etag: res.headers.get('etag') ?? undefined, items }; + 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 }; } /** From 07e672183d2728b1ac2d51cae149ab00772f7ece Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 10:19:20 +0000 Subject: [PATCH 7/7] chore(changeset): the adapter's new loadAppBundle widens its public surface Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo --- .changeset/27627-console-app-metadata-bundle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27627-console-app-metadata-bundle.md b/.changeset/27627-console-app-metadata-bundle.md index 771c4307f26..70ea2e0d062 100644 --- a/.changeset/27627-console-app-metadata-bundle.md +++ b/.changeset/27627-console-app-metadata-bundle.md @@ -5,7 +5,7 @@ feat(console): entering an app loads its metadata bundle once, and a record open reads no metadata -Clause-②: no +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.