Repository navigation
Commit a9f1452
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
File tree
- .changeset
- content/docs/frontend
- ui/packages
- app-shell/src
- console
- __tests__
- views
- data-objectstack
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
234 | 234 | | |
235 | 235 | | |
236 | 236 | | |
| 237 | + | |
237 | 238 | | |
238 | 239 | | |
239 | 240 | | |
240 | 241 | | |
241 | 242 | | |
242 | | - | |
| 243 | + | |
243 | 244 | | |
244 | 245 | | |
245 | 246 | | |
246 | 247 | | |
247 | 248 | | |
248 | 249 | | |
249 | 250 | | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
250 | 279 | | |
251 | 280 | | |
252 | 281 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
387 | 387 | | |
388 | 388 | | |
389 | 389 | | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
390 | 402 | | |
391 | 403 | | |
392 | 404 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
215 | 218 | | |
216 | 219 | | |
217 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
150 | 153 | | |
151 | 154 | | |
152 | 155 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
166 | 169 | | |
167 | 170 | | |
168 | 171 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
30 | 35 | | |
31 | 36 | | |
32 | 37 | | |
| |||
89 | 94 | | |
90 | 95 | | |
91 | 96 | | |
92 | | - | |
| 97 | + | |
| 98 | + | |
93 | 99 | | |
94 | 100 | | |
95 | 101 | | |
| |||
112 | 118 | | |
113 | 119 | | |
114 | 120 | | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
115 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
116 | 132 | | |
117 | 133 | | |
118 | 134 | | |
| |||
136 | 152 | | |
137 | 153 | | |
138 | 154 | | |
139 | | - | |
| 155 | + | |
140 | 156 | | |
141 | 157 | | |
142 | 158 | | |
| |||
222 | 238 | | |
223 | 239 | | |
224 | 240 | | |
225 | | - | |
226 | | - | |
227 | | - | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
228 | 244 | | |
229 | 245 | | |
230 | 246 | | |
| |||
327 | 343 | | |
328 | 344 | | |
329 | 345 | | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
330 | 375 | | |
331 | 376 | | |
332 | 377 | | |
| |||
0 commit comments