Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/27627-console-app-metadata-bundle.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 30 additions & 1 deletion content/docs/frontend/data-objectstack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,48 @@ 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 ObjectStackAdapter<unknown>, keyof DataSource<unknown>>` lists
them all; the rest are lower-level seams — client discovery, cache-key
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
Expand Down
12 changes: 12 additions & 0 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
ModalForm: () => null,
}));

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null }));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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 = (
<I18nProvider instance={i18n}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>
</I18nProvider>
);
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']));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,7 +94,8 @@ const TOTAL: Record<string, number> = { 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<string, string> = {}) =>
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) {
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading