Skip to content

Commit a9f1452

Browse files
feat(console): entering an app loads its metadata bundle once, and a record open reads no metadata (#28321)
domain:ui#2 · os-tesla · session_01L9qibzFu8iUogiZhxL9tAo <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFLwBbJajdZeJ5fe3Ys5aEN)_ Fixes #27627 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:控制台进入一个应用时,用一个请求取回该应用的元数据包(`GET /api/v1/meta/app/{appId}/bundle`)。之后包里有的对象、视图、页面、仪表盘和应用本身,一律从包里读,不再逐个请求。再次进入应用时带 `If-None-Match`,服务端回 304 就继续用手上这份。 - 为什么改:#27627 的 Target 3 元数据半边(「27627 插队」)。PR 1 之后,打开一条记录还剩第二波 `/meta/object/…` 读取。本 PR 把这一波去掉了:热会话打开记录只发 1 个页面请求、0 个元数据请求;冷会话最多再加 1 个元数据包请求,而且和页面请求同一波发出。改前 main 上是 1 个页面请求加 5 个 `/meta/object`,分两波。 - 风险与回滚:只改 ui 的 `data-objectstack` 和 `app-shell`;对外只新增适配器的一个公开方法 `loadAppBundle`(条款② 放宽)。包里没有的条目照旧走单条路由,包括 Studio、应用以外的对象,以及调用者无权读、因此不在包里的条目。任何经客户端的元数据写入都会让包先重新校验再答下一次读取;切换组织或语言会清空缓存,包随之重新下载。服务端没有这条路由或拒绝时,行为和现在一样。回滚即 revert 本 PR。 - 先看:`ui/packages/data-objectstack/src/app-bundle.ts`(包的存放、校验与失效);`index.ts` 里的 `installBundledItemReads` 和 `fetchObjectSchemaFresh`(两个读取入口);`app-shell/src/console/AppContent.tsx` 里的 `loadAppBundle` 调用(只对 URL 指名的应用)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx packages/data-objectstack/src/appBundle-27627.test.ts packages/app-shell/src/console/__tests__/AppContent.loadAppBundle-27627.test.tsx`,预期 3 个文件、21 个测试全绿。 - 需要你做的:无,席位落地(生产方 PR #28287 已合并)。 ## What changes The console's metadata reads on app entry and on a record open, measured on `main` `69ce01d7` through the record page's own acceptance transport (`RecordDetailView.recordPagePayload-27627`, StrictMode, the real adapter): | open | before (this transport, `main`) | after | |:--|:--|:--| | cold (first record in the session) | `POST …/page` + 5 × `GET /meta/object/NAME`, in two waves (the contact's own definition in wave 2) | `POST …/page` + 1 × `GET /meta/app/APP/bundle`, both in wave 1 | | warm, the app entered earlier, no record opened yet | the same 5 by-name reads | `POST …/page` alone, 0 metadata requests | **Load once, revalidate after** (`ui/packages/data-objectstack/src/app-bundle.ts`, `ObjectStackAdapter.loadAppBundle`): - `loadAppBundle(appId)` asks `GET /api/v1/meta/app/{appId}/bundle` through the client's conditional `meta.getAppBundle(appId, { ifNoneMatch })` (#28287). Calling it again for an app already held sends `If-None-Match` with the held validator as the server spelled it (the producer's weak `W/"…"` included, rebuilt from the client's `etag: { value, weak }`), and a `304` (`notModified`) keeps the bundle. An author-set `If-None-Match` makes the browser pass the server's `304` through rather than answer from its own cache. - One bundle is kept per app entered this session. The last app entered answers reads, and the others are kept so that re-entering one revalidates it instead of downloading it again. - The body is read as the producer declares it, typed by `AppMetadataBundleResponse` (`z.input` of `AppMetadataBundleResponseSchema`, `@objectstack/spec/api`): bare, with every member the `GetMetaItemResponse` envelope its own `GET /meta/{type}/{name}` returns. Each envelope is kept whole and handed back as that read's answer. **Serve the by-name reads of bundled items** (`index.ts`): - `installBundledItemReads` wraps the client's `meta.getItem(type, name)`, the one door that `getView`, `getPage`, `getApp`, `getItems`, `probeMetaItemAccess` and app-shell's `MetadataProvider` (through `getClient()`) all go through. An item the active bundle holds is answered there, as a copy of its envelope. A call with options, and any item the bundle does not hold, goes to the route. - `fetchObjectSchemaFresh`, the adapter's raw `GET /meta/object/NAME`, now asks the bundle first. The unwrap, `disabledActions` and `sortability` stamping that follow are unchanged, so a bundled object carries the same symbols its by-name read gave it. - A read made while the bundle is in flight waits for it, so a record opened as the app is entered sends no by-name read for anything the bundle holds. **Staying current:** - After a metadata write through any of the client's write doors (`saveItem`, `deleteItem`, `publishItem`, `rollbackItem`) settles, the bundles are marked stale. So is any `invalidateCache(key)` whose key names a held item, in the cache-key spellings the adapter and its hosts use (a bare object name, `view:OBJECT:NAME`, `TYPE:NAME` from app-shell `MetadataService`, and `list:TYPE`, which covers every item of that type). The same applies to `invalidateViewKeys` for a held view. - A stale bundle, or one older than the adapter cache's lifetime, revalidates with `If-None-Match` before it answers its next read. That read never goes by name, so there is no dual path for a bundled item. - `clearCache()` (organization, session or locale switch) drops every bundle's items and validator, and the next read downloads the bundle again. The response varies on `Accept-Language`, and this is what makes a locale switch refetch. **App entry** (`AppContent.tsx`): an effect calls `loadAppBundle(matchedApp.name)` when the URL names an app. That holds on the package-id route the sidebar builds and on the name alias. A pseudo-route's fallback app is not entered. A host adapter without `loadAppBundle` is left alone (protocol-agnostic, AGENTS #1). **Unchanged:** list reads (`GET /meta/object`, `/meta/view`, `/meta/app`, the package-scoped object list) keep their routes, and so do the preview (`?preview=draft`) client and Studio's `MetadataClient`. ## Tests - `ui/packages/data-objectstack/src/appBundle-27627.test.ts` (new, 11), through the real adapter and client over a held transport: - a read made while the bundle loads waits, and nothing it holds (object, child object, view, page, app, dashboard) is asked by name; - an unheld item and a read with options keep the route; - re-entry sends `If-None-Match`, and a `304` keeps what is held; - an invalidation naming a bundled object asks the bundle again (with the validator), not the item; - an invalidation naming nothing held asks nothing; - a metadata save makes the next bundled read revalidate; - a cache clear re-downloads with no validator; - a bundled object carries its envelope's `sortability`; - a weak validator goes back byte-for-byte, `W/` included; - a bundle served `no-store` answers the reads that waited on it and is not kept: the next read of a member goes by name, and re-entry downloads it with no validator; - a refused bundle (the app read's own `403`) leaves reads on their routes. - `RecordDetailView.recordPagePayload-27627.test.tsx`, two cases added (the card's acceptance): - cold, with the app entered as the record opens: metadata = exactly one `GET` of the bundle, data = exactly the page request, all in wave 1; - warm, with the app entered earlier and no record opened: the record open sends exactly one page request and zero metadata requests; the 动态 stream's own data reads (#28116) are counted apart. - The existing cold case's comment now says that without an app entered, the definitions are still read by name. - `AppContent.loadAppBundle-27627.test.tsx` (new, 2), on a real `AppContent` mount: the package-id route and the name alias each load the bundle by the app's name, once. - Three AppContent verdict files (`deniedVsUnpublished`, `settingsMarketplaceVerdict-27507`, `setupItemRoutesVerdict-12706`) count by-name app probes with a regex that also matched the new bundle URL. Their transports now answer the bundle as refused before the probe regex runs. The probe assertions themselves are unchanged. ## Acceptance notes - **Measured deviation: withheld vs outside.** The order reads "an item withheld from the caller is answered as withheld, not fetched by name". The producer's bundle (#28287) names nothing it withheld: a withheld item is simply absent, indistinguishable from an item outside the app. So the console cannot tell the two apart, and an absent name keeps its by-name read, which answers that item's own refusal. Measured in `appBundle-27627.test.ts` (an unheld item goes to its route). The seat and spec seat 1 agreed this reading. - **Through the client, typed by the spec.** With #28287 on `main`, `requestAppBundle` calls the client's `meta.getAppBundle(appId, { ifNoneMatch })` and `indexAppBundle` takes `AppMetadataBundleResponse`. The client reports the validator as `{ value, weak }` with the quotes and `W/` taken off, so the adapter rebuilds `W/"value"` (or `"value"`) to send back; for a well-formed entity tag that is the header byte-for-byte (pinned with a `W/"sha256-…"` tag). The client does not expose `Cache-Control`, so a `200` with no `ETag` is read as the producer's `no-store` answer, the only one it serves without a validator. - **Clause-② re-measured on the built declarations: `yes (widening)`.** The dispatch expected `no`. Built `ui/packages/data-objectstack/dist/index.d.ts` gains one public member on the exported `ObjectStackAdapter`, `loadAppBundle(appId: string): Promise<void>` (plus the private `appBundles` / `requestAppBundle`, which consumers cannot reach). `AppBundleStore` and `indexAppBundle` are not exported. `@object-ui/app-shell`'s built declarations name nothing new. `Metadata-protocol: none` stands: nothing a metadata author writes changes, so the changeset stays `patch` (AGENTS.md Post-Task Checklist step 3). - **Only the URL-named app is entered.** `AppContent` loads `matchedApp`, not `activeApp`, so a pseudo-route (Setup, marketplace) that falls back to a default app does not load that app's bundle. This also keeps the three verdict suites' probe counts honest. - **The 动态 stream (#28116) is not metadata.** Since #28116 the record page's right column opens with the 动态 panel, which reads `sys_comment`, `sys_activity` and the mention list (`sys_user`) beside the page request. Those are data reads, pinned by main's own cases; the acceptance cases here count them separately and assert 0 metadata requests and exactly one page request. - **Revalidation follows the adapter cache's lifetime.** A bundle answer stands for `cache.ttl` (default 5 min), like the by-name answers it replaces. Past that, the next bundled read revalidates with `If-None-Match` first, so a warm open after the lifetime costs one `304` round trip, not five by-name reads. - **A `no-store` bundle is not kept.** When a member's field visibility is undetermined the producer serves `private, no-store` and no `ETag`; the adapter reads the missing `ETag`. The bundle answers the reads that were waiting on it (the open in progress), and then the store holds nothing for that app. Later reads go by name until the app is entered again, which downloads it with no validator. The adapter's own `MetadataCache` still keeps an object definition it read during that open for its lifetime, exactly as it does for a by-name answer today; this PR does not change that cache. - **Live boot: NOT MEASURED.** The route is on `main` since #28287, but no live server was booted for this PR. The acceptance counts are measured through the record page's real adapter over its acceptance transport, which now serves the bundle with a weak `ETag` and `private, no-cache`, as the route does. A server without the route answers `404`, and the console behaves as it does today (pinned: a refused bundle leaves every read to its route). - **Landing order:** producer PR #28287 merged (`8441a012`). This PR is the card's last part, so it says `Fixes #27627`. The seat lands it. ## Verification All on head `07e67218`. Producer #28287 (`8441a012`) and the rest of `main` were merged in, then the switch to the client's `getAppBundle` and the spec type, then the changeset's `Clause-②: yes (widening)`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands (the same set as on every earlier head). All 67 were run with `</dev/null`, and every one exited 0. Reconcile `--ran` exit 0: 67 derived, 67 run, 0 NOT-MEASURED. Also `pnpm --dir ui run check:new-line-citations` exit 0 (0 new citations) and `pnpm check:nul-bytes` exit 0. `node scripts/check-sdui-manifest.mjs` is in the derived list. - Built after `pnpm install --frozen-lockfile`, with a declarations build of the `@object-ui/app-shell` closure and `@objectstack/client-react...`. - Type-check: - `ui/packages/data-objectstack` `tsc --noEmit` exit 0. - `ui/packages/app-shell` `tsc --noEmit` exit 0 and `tsc -p tsconfig.test.json` exit 0. - Clause-② measure: the built `ui/packages/data-objectstack/dist/index.d.ts` names `loadAppBundle(appId: string): Promise<void>` as the one new public member. `ui/packages/app-shell/dist` declarations name no bundle symbol. - Suites: `pnpm --dir ui exec vitest run packages/data-objectstack packages/app-shell/src/console packages/app-shell/src/views/RecordDetailView` ran 419 files and 3336 tests, all green. That includes the 3 card files (21 tests) and the three AppContent verdict files. - Ablations through `node scripts/ablation-replace.mjs`. Each was restored with the blob equal to `HEAD` and an empty diff. - On this head: - With the weak flag dropped from the rebuilt validator, 4 cases go red, the weak-validator pin among them. - With the no-`ETag` branch off (the bundle kept), the `no-store` case goes red. - On earlier heads, with the same code: - With the bundle read off in `fetchObjectSchemaFresh`, 7 tests go red. - With the app-entry `loadAppBundle` call off, both AppContent pins go red. - With the write-door staleness off, the metadata-write case goes red. - Merge state against `origin/main`: clean. - Diff: 12 files, +923/−32. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzFu8iUogiZhxL9tAo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d3aa8c commit a9f1452

12 files changed

Lines changed: 923 additions & 32 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(console): entering an app loads its metadata bundle once, and a record open reads no metadata
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **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.

‎content/docs/frontend/data-objectstack.mdx‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,19 +234,48 @@ either form):
234234

235235
- `connect()` - Establish the connection (called lazily by every operation)
236236
- `getCacheStats()` / `invalidateCache(key?)` / `clearCache()` - Cache control
237+
- `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))
237238
- `getClient()` - Access the underlying `@objectstack/client` instance
238239
- `getConnectionState()` / `isConnected()` - Connection introspection
239240
- `onConnectionStateChange(listener)` - Subscribe to state changes (returns unsubscribe)
240241
- `onBatchProgress(listener)` - Subscribe to bulk progress (returns unsubscribe)
241242

242-
Those six bullets cover nine members, and they are the documented subset.
243+
Those seven bullets cover ten members, and they are the documented subset.
243244
`ObjectStackAdapter` declares more members beyond `DataSource` than these —
244245
`Exclude<keyof ObjectStackAdapter<unknown>, keyof DataSource<unknown>>` lists
245246
them all; the rest are lower-level seams — client discovery, cache-key
246247
invalidation, dataset and dashboard access and advisory subscriptions among
247248
them — which this page does not document and which carry no compatibility
248249
promise here.
249250

251+
### App metadata bundle
252+
253+
The console calls `loadAppBundle(appName)` when it enters an app. One request,
254+
`GET /api/v1/meta/app/{appId}/bundle`, returns the app, the objects its
255+
navigation reaches with the objects that reference them, and their views,
256+
pages and dashboards. Each one is the envelope its own
257+
`GET /meta/{type}/{name}` read would return to this caller.
258+
259+
- A by-name read of an item the bundle holds is answered from the bundle and
260+
never sent: `getObjectSchema`, `getView`, `getPage`, `getApp`, `getItems`,
261+
and the client's `meta.getItem(type, name)` with no options.
262+
- An item the bundle does not hold keeps its by-name read. That covers objects
263+
outside the app, reads made with options, and an item withheld from the
264+
caller, which is absent from the bundle and gets its own read's refusal.
265+
- Calling `loadAppBundle` again for the same app sends `If-None-Match` with the
266+
held `ETag` exactly as served (a weak `W/` validator included), and a `304`
267+
keeps the bundle already held.
268+
- A bundle served `no-store` with no `ETag` (the server could not settle a member's field
269+
visibility) answers the reads that waited on it and is not kept. Later reads
270+
go by name until the app is entered again.
271+
- A metadata write through the client, or an `invalidateCache(key)` that names
272+
a bundled item, makes the next bundled read revalidate the bundle first. So
273+
does an answer older than the cache TTL. `clearCache()` drops every bundle,
274+
and the next read downloads it again.
275+
276+
If the bundle is refused, or the server has no such route, every read stays on
277+
its by-name route.
278+
250279
### Per-element data binding
251280

252281
A schema node's `dataSource` key is the spec's `ElementDataSource` (validated by

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
387387
// (objectui#11818).
388388
const appSegment = appAddressSegment(apps, matchedApp) ?? appName ?? appRouteSegment(activeApp);
389389

390+
// objectstack#27627 — entering an app loads its metadata bundle, and
391+
// re-entering it revalidates the one held (`loadAppBundle`): from then on the
392+
// by-name metadata reads of everything the app reaches are answered from it.
393+
// Only the app the URL names: a pseudo-route's fallback app is not entered.
394+
// A host adapter without the bundle keeps every by-name read.
395+
const bundleAppName: string | undefined = typeof matchedApp?.name === 'string' ? matchedApp.name : undefined;
396+
useEffect(() => {
397+
const load = dataSource?.loadAppBundle;
398+
if (!bundleAppName || typeof load !== 'function') return;
399+
void load.call(dataSource, bundleAppName);
400+
}, [dataSource, bundleAppName]);
401+
390402
// objectstack-ai/objectstack#26183 — a URL that reached the app through its
391403
// NAME alias moves to the app's one address, path, search, hash and history
392404
// state kept. The alias still opens (old bookmarks and shared links keep

‎ui/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ function makeAdapter() {
212212
if (url.includes('/api/v1/discovery')) {
213213
return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } });
214214
}
215+
// objectstack#27627 — entering an app loads its metadata bundle; that is
216+
// not the by-name read this file asks about, and it answers as refused.
217+
if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY);
215218
const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url);
216219
if (item) {
217220
metaItemRequests.push(url);
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27627 — entering an app loads its metadata bundle, by the app's
11+
* NAME (the address `GET /meta/app/:name/bundle` takes), on the package-id
12+
* route the sidebar builds and on the name alias alike; staying in the app
13+
* asks it once.
14+
*
15+
* Measured from a real `AppContent` mount (the
16+
* `AppContent.recentOnPackageRoute-11818` harness); the adapter's
17+
* `loadAppBundle` is the observation point.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import React from 'react';
23+
import { render, cleanup, screen } from '@testing-library/react';
24+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
25+
import { createI18n, I18nProvider } from '@object-ui/i18n';
26+
27+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
28+
...(await importOriginal<Record<string, unknown>>()),
29+
ModalForm: () => null,
30+
}));
31+
32+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
33+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
34+
CreateAppPage: () => <div data-testid="create-app-page" />,
35+
EditAppPage: () => <div data-testid="edit-app-page" />,
36+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
37+
}));
38+
39+
vi.mock('../../layout/ConsoleLayout', () => ({
40+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
41+
<div data-testid="console-layout">{children}</div>
42+
),
43+
}));
44+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
45+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
46+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
47+
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
48+
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null }));
49+
50+
vi.mock('@object-ui/auth', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
useAuth: () => ({
53+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
54+
getAuthConfig: async () => ({ features: {} }),
55+
activeOrganization: null,
56+
}),
57+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
58+
}));
59+
60+
const { loadAppBundle } = vi.hoisted(() => ({ loadAppBundle: vi.fn(async (_appId: string) => {}) }));
61+
const dataSourceStub = {
62+
onConnectionStateChange: () => () => {},
63+
getConnectionState: () => 'connected',
64+
loadAppBundle,
65+
};
66+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
67+
...(await importOriginal<Record<string, unknown>>()),
68+
useAdapter: () => dataSourceStub,
69+
}));
70+
71+
/** The showcase app as the catalog serves it: a name AND a package id. */
72+
const APPS = [
73+
{
74+
name: 'showcase_app',
75+
label: 'Showcase',
76+
_packageId: 'com.example.showcase',
77+
isDefault: true,
78+
navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }],
79+
},
80+
];
81+
82+
const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }];
83+
84+
// One value for the whole run: the route's effects key on these members, and
85+
// a fresh value per render would re-run them without end.
86+
const METADATA = {
87+
apps: APPS,
88+
objects: OBJECTS,
89+
loading: false,
90+
ensureType: undefined,
91+
error: null,
92+
refresh: async () => {},
93+
};
94+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
95+
...(await importOriginal<Record<string, unknown>>()),
96+
useMetadata: () => METADATA,
97+
}));
98+
99+
const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
100+
vi.mock('@object-ui/react', async (importOriginal) => ({
101+
...(await importOriginal<Record<string, unknown>>()),
102+
useActionRunner: () => ACTION_RUNNER,
103+
useGlobalUndo: () => {},
104+
useMutationInvalidationBridge: () => {},
105+
}));
106+
107+
import { AppContent } from '../AppContent';
108+
109+
beforeEach(() => {
110+
loadAppBundle.mockClear();
111+
});
112+
afterEach(cleanup);
113+
114+
async function visit(path: string) {
115+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
116+
const view = (
117+
<I18nProvider instance={i18n}>
118+
<MemoryRouter initialEntries={[path]}>
119+
<Routes>
120+
<Route path="/apps/:appName/*" element={<AppContent />} />
121+
</Routes>
122+
</MemoryRouter>
123+
</I18nProvider>
124+
);
125+
const mounted = render(view);
126+
await screen.findByTestId('object-view', undefined, { timeout: 5000 });
127+
return { rerender: () => mounted.rerender(view) };
128+
}
129+
130+
describe('AppContent loads the entered app\'s metadata bundle (objectstack#27627)', () => {
131+
it('on the package-id route, by the app name, once', async () => {
132+
const { rerender } = await visit('/apps/com.example.showcase/showcase_task');
133+
rerender();
134+
expect(loadAppBundle.mock.calls).toEqual([['showcase_app']]);
135+
});
136+
137+
it('on the name alias, by the same name', async () => {
138+
await visit('/apps/showcase_app/showcase_task');
139+
expect(loadAppBundle.mock.calls.map(([app]) => app)).toEqual(expect.arrayContaining(['showcase_app']));
140+
expect(new Set(loadAppBundle.mock.calls.map(([app]) => app))).toEqual(new Set(['showcase_app']));
141+
});
142+
});

‎ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ function makeAdapter() {
147147
if (url.includes('/api/v1/discovery')) {
148148
return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } });
149149
}
150+
// objectstack#27627 — entering an app loads its metadata bundle; that is
151+
// not the by-name read this file asks about, and it answers as refused.
152+
if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY);
150153
const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url);
151154
if (item) {
152155
metaItemRequests.push(url);

‎ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ function makeAdapter() {
163163
if (url.includes('/api/v1/discovery')) {
164164
return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } });
165165
}
166+
// objectstack#27627 — entering an app loads its metadata bundle; that is
167+
// not the by-name read this file asks about, and it answers as refused.
168+
if (/\/api\/v1\/meta\/app\/[^/?]+\/bundle$/.test(url)) return json(404, ABSENT_BODY);
166169
const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url);
167170
if (item) {
168171
metaItemRequests.push(url);

‎ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
* the page request, in the same wave: the record's comment thread, its
2828
* activity and, once per view, the mention list. None of them is a part of the page the payload answers;
2929
* they are pinned on their own below, not counted as page requests.
30+
*
31+
* And once the app's metadata bundle is in (`loadAppBundle`, on app entry), the
32+
* object definitions the blocks read come from it: a record open in a warm
33+
* session asks the page and no metadata at all (its 动态 stream's own reads aside), and a cold one adds at most
34+
* the one bundle request.
3035
*/
3136
import * as React from 'react';
3237
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -89,7 +94,8 @@ const TOTAL: Record<string, number> = { os_27627_task: 7, os_27627_case: 4, os_2
8994

9095
interface Held { wave: number; method: string; path: string; body: any; released: boolean; release: () => void }
9196

92-
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
97+
const json = (body: unknown, headers: Record<string, string> = {}) =>
98+
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json', ...headers } });
9399

94100
/** The page payload the route answers for `body`, for record `id`. */
95101
function pageAnswer(id: string, body: any) {
@@ -112,7 +118,17 @@ function pageAnswer(id: string, body: any) {
112118
};
113119
}
114120

121+
const APP = 'demo';
122+
const BUNDLE = `/api/v1/meta/app/${APP}/bundle`;
123+
124+
/** The bundle carries its per-caller validator, as the route serves a bundle it may be kept by. */
125+
const BUNDLE_HEADERS = { ETag: 'W/"demo-bundle-1"', 'Cache-Control': 'private, no-cache' };
126+
115127
function answerFor(h: Held): unknown {
128+
if (h.path === BUNDLE) {
129+
const env = (type: string, item: { name: string }) => ({ type, name: item.name, item });
130+
return { app: env('app', { name: APP }), objects: ALL.map((o) => env('object', o)), views: [], pages: [], dashboards: [] };
131+
}
116132
const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path);
117133
if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null };
118134
const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path);
@@ -136,7 +152,7 @@ function heldTransport() {
136152
wave: state.wave, method, path,
137153
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
138154
released: false,
139-
release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h))); },
155+
release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h), h.path === BUNDLE ? BUNDLE_HEADERS : {})); },
140156
};
141157
held.push(h);
142158
});
@@ -222,9 +238,9 @@ describe('objectstack#27627 — one request opens a record page', () => {
222238
const data = dataRequests(held);
223239
expect(data.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]);
224240
expect(held.filter((h) => /[?&](\$?top)=1\b/.test(h.path))).toEqual([]);
225-
// Every DATA request goes out in the first wave. The object definitions the
226-
// blocks read (`/meta/object/…`) are the app bundle's to serve once per
227-
// session; until then they are read as before, beside it.
241+
// Every DATA request goes out in the first wave. With no app entered there
242+
// is no metadata bundle, so the object definitions the blocks read
243+
// (`/meta/object/…`) are read by name beside it; with one, see below.
228244
expect(data.every((h) => h.wave === 1)).toBe(true);
229245

230246
// The request names what the page draws: its four related lists, the
@@ -327,6 +343,35 @@ describe('objectstack#27627 — one request opens a record page', () => {
327343
expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1']);
328344
});
329345

346+
it('a cold session, entering the app as the record opens, adds one bundle request and no by-name metadata read', async () => {
347+
const { held, state, open, ds } = await boot();
348+
void ds.loadAppBundle(APP);
349+
open('c1');
350+
await runWaves(held, state);
351+
expect(held.filter(isMeta).map((h) => `${h.method} ${h.path}`)).toEqual([`GET ${BUNDLE}`]);
352+
expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]);
353+
// Both go out in the first wave, beside the 动态 stream's own reads: nothing waits on the other.
354+
expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']);
355+
expect(held.every((h) => h.wave === 1)).toBe(true);
356+
});
357+
358+
it('a warm session opens a record with one page request and zero metadata requests', async () => {
359+
const { held, state, open, ds } = await boot();
360+
// The app was entered earlier in the session; no record was opened yet,
361+
// so no object definition was ever read by name.
362+
const entered = ds.loadAppBundle(APP);
363+
await runWaves(held, state);
364+
await entered;
365+
held.length = 0;
366+
state.wave = 1;
367+
open('c1');
368+
await runWaves(held, state);
369+
expect(held.filter(isMeta)).toEqual([]);
370+
expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]);
371+
// What else goes out is the 动态 stream's own data reads (objectstack#27524), no metadata.
372+
expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']);
373+
});
374+
330375
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 () => {
331376
const { held, state, open, ds } = await boot();
332377
Object.assign(ds, { getRecordPage: undefined, prefetchRecordPage: undefined });

0 commit comments

Comments
 (0)