Skip to content

Commit 0027c4a

Browse files
feat(rest,console): one request opens a record page (#27713)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP Part of #27627 Claim: 6501011060 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none edited. No skill teaches which requests a record page sends when it opens, and the API skill's spec index (`skills/objectstack-api/references/_index.md`) lists schema files it reads from the installed package, so the new `api/record-page.zod.ts` needs no row (`check:skill-refs` exit 0). Named, not edited. **`Metadata-protocol: added`, measured.** The claim's `added` named the app bundle (PR 2); this PR measures `added` for itself. The new request and response shapes enter `packages/spec/authorable-surface/api.json`, the authorable-surface shard the level axis reads, so `check-changeset-no-major --event` refuses `none` here. Nothing an author already writes changes meaning: no existing key, default or answer moves. Per the rule, every package this PR moves takes `minor` (both changesets). ## 维护者速读 - **改了什么:** 新增 `POST /api/v1/data/{object}/{id}/page`,一次请求返回记录详情页打开时要的全部数据:记录本身(含查找字段展开)、每个相关列表的总数和前几行、字段权限、审批状态与锁、页头按钮的权限判定。控制台记录页(整页、分栏、抽屉)改为只发这一个请求,再把各部分交给原来各自去取的组件;抽屉里预取下一条将要显示的记录。 - **为什么改:** 以前打开一条记录要分两到三批串行请求(测试里冷启动 13 个分两批、热启动 8 个;HotCRM 浏览器实测 45 / 23 个;改后冷启动只剩 1 个数据请求加对象定义读取,热启动 1 个),每个相关列表还要单独发一次 `top=1` 计数探测,远距离部署下每批都要多等一个往返。 - **风险与回滚:** 每一部分都是在服务端以同一调用者身份调用被替代请求的同一个函数,所以对象权限、行级安全、共享和字段级安全照旧生效;看不到的相关列表只带拒绝,不带总数也不带行。打开之后的刷新、写入后重读仍走各组件自己的请求。回滚 = revert 本 PR。 - **先看:** `packages/rest/src/record-page-routes.ts`(每一部分调用哪个函数、父记录条件怎么拼)、`packages/qa/dogfood/test/record-page.dogfood.test.ts`(非管理员成员下与分开请求逐项对比)、`ui/packages/app-shell/src/views/recordPagePayload.ts`(页面要哪些列表、答案交给谁)。 - **两分钟验证:** `cd packages/qa/dogfood && pnpm exec vitest run test/record-page.dogfood.test.ts` → 3 个用例绿:非管理员成员拿到的记录、相关总数与行、字段权限、判定、审批与分开请求一致,无权限的相关列表 403 且无总数无行。 - **需要你做的:** 无。本 PR 超过 3000 行属 Tier H,维护者已批准(os-zhuang,2026-10-30 12:49Z),席位核对后入队落地。 ## What changes ### Request counts, before and after Measured in a console test harness over the real `ObjectStackAdapter` on a held transport (each "wave" is one round: everything asked so far is answered together, and what the page asks next is the next wave). The record is a contact with one lookup and four related lists, opened in StrictMode on the default record page; the same harness file ran on `origin/main` (the `ui/packages` tree checked out from `origin/main`) and on this head, and both runs draw the record. <details><summary>Correction to the figure quoted to the seat (warm 9 → 8)</summary> The first harness run answered the record read (`GET /data/{object}?filter=["id","=",…]`) with no row, so it measured a "Record not found" page; its warm figure was 9 because the not-found page also read the approval lock. With the record answered, `origin/main` reads cold 13 in 2 waves and warm 8 in 1 wave. The cold figure is unchanged. </details> **Before, `origin/main` at the merge base `178b5238`:** ```text COLD 13 requests, 2 waves w1 GET /api/v1/approvals/requests?object=os_27627_contact&recordId=c1 w1 POST /api/v1/security/explain {"operation":"update",…} w1 POST /api/v1/security/explain {"operation":"delete",…} w1 GET /api/v1/data/os_27627_contact?populate=account&top=1&filter=["id","=","c1"] w1 GET /api/v1/meta/object/os_27627_task (and _case, _deal, _event) w2 GET /api/v1/data/os_27627_task?top=1&filter=["contact","=","c1"] (and _case, _deal, _event) w2 GET /api/v1/meta/object/os_27627_contact WARM 8 requests, 1 wave w1 approvals list, explain update, explain delete, the record, four top=1 probes ``` **After, this head:** ```text COLD 6 requests, 2 waves w1 POST /api/v1/data/os_27627_contact/c1/page {"expand":["account"],"related":[4 lists, top 3],"explain":["update","delete"],"fieldAccess":true,"approvals":true} w1 GET /api/v1/meta/object/os_27627_task (and _case, _deal, _event) w2 GET /api/v1/meta/object/os_27627_contact WARM 1 request, 1 wave w1 POST /api/v1/data/os_27627_contact/c2/page {…} ``` | | Before | After (this PR) | |:--|--:|--:| | Cold open, data requests | 8 (4 in wave 1, 4 probes in wave 2) | **1**, wave 1 | | Cold open, `top=1` probes | 4 | **0** | | Cold open, object definitions | 5 (one in wave 2) | 5 (one in wave 2), unchanged until PR 2 | | Warm open, all requests | 8 | **1** | | Step to a prefetched neighbour (split pane or drawer, ↑ ↓ or a row click) | a full open | **0** for the record it opens; its own neighbours are then asked ahead (see "Merge with #28129") | The only second-wave request left is the record object's own definition (`/meta/object/<object>`), which the details block reads once the record arrives; it was there before too, and it is what PR 2's app bundle serves. ### The route: `POST /api/v1/data/{object}/{id}/page` The query form (a POST with a strict body) was chosen because the page must name its related lists, their declared filters and their order, and that does not fit a closed query string. Seat ruling: accepted, `Clause-②: yes (widening)`. **Request** (`RecordPageRequestSchema`, `@objectstack/spec/api`, strict; the query-parameter set is closed and empty): ```ts { expand?: string[]; // lookups to resolve, as GET /data/{object}/{id}?expand= related?: Array<{ // max 50 object: string; // the child object field: string; // its lookup/master_detail field referencing {object} filter?: unknown; // the list's declared scope, AND-composed with the parent condition top: number; // 0..50 first rows sort?: Array<{ field: string; order: 'asc' | 'desc' }>; // max 8 }>; explain?: Array<'update' | 'delete' | 'transfer'>; fieldAccess?: boolean; approvals?: boolean; } ``` **Response `data`** (`RecordPageResponseSchema`): ```ts { object: string; id: string; record: Record<string, unknown>; // as GET /data/{object}/{id} related: Array<{ object; field; total?; records?; error? }>; // request order fieldAccess?: GetRecordFieldAccessResponse | { error }; explain?: { update?; delete?; transfer?: ExplainDecision | { error } }; approvals?: { requests: Array<…>; lock: { locked: boolean } | null } | { error }; } ``` **Every part is the read it replaces, asked as the same caller, through the same function:** | Part | The read it replaces | Called through | |:--|:--|:--| | `record` | `GET /data/{object}/{id}` | `getData`, after the same object-exposure gate, asked with the caller's context | | `related[i]` | `GET /data/{child}?filter=…&$count` | `findData` with the canonical QueryAST (`where`, `limit`, `orderBy`, `count: true`), after the child's own exposure gate | | `fieldAccess` | `GET /data/{object}/{id}/field-access` | `getRecordFieldAccess` | | `explain.<op>` | `POST /security/explain` with the record id | the security service's `explain` | | `approvals` | `GET /approvals/requests` for the record and `GET /approvals/records/{object}/{id}/lock` | `listRequests` (the pending row enriched by `getRequest`) and `getRecordLock` | - **A record the caller cannot read refuses the whole request** with the record read's own answer, before any other part is asked, so no part answers for a record the caller could not open. - **A part whose read refuses carries that refusal** (`{ code, status, message }`) in place of its answer, and the other parts are answered. ⛔ A related list the caller may not read carries neither `total` nor `records`. - **A related entry must be a relationship to this record**: a lookup or master-detail field of the child that references `{object}`, or a `400 VALIDATION_FAILED` whose `error.details.fields` locates `invalid_relationship` at `related.N.field`, before anything is read. Both request refusals answer through the shared `sendError`; the catch relays `mapDataError` exactly as `GET /data/{object}/{id}` does (declared in `check-route-envelope.mjs` with the data family's #9559 ratchet). The parent condition is the server's, compiled to the field's arity (`$contains` for a multi-value field); a declared `filter` can only narrow it. - **Server-Timing.** The route's metadata reads are timed as a `meta` span beside the existing `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin), per the seat's ruling. Opening `Server-Timing` to every caller would be a maintainer card. `Cache-Control` and `ETag` already answer on the metadata item routes (`/meta/object/{name}`), so nothing moves there in this PR; the bundle in PR 2 carries its own `ETag`. - `@objectstack/client`: `data.recordPage(object, id, request)`. Route ledger row `POST /api/v1/data/:object/:id/page` (`disposition: 'sdk'`, `client: 'data.recordPage'`, `responseSchema: 'RecordPageResponseSchema'`). ### The console - `RecordDetailView` builds the request from the page it renders (the `record:related_list` nodes, the `record:reference_rail` entries and the lists derived for a synthesized page; the header's `update` / `delete` verdicts and `transfer` when the object has a queue; field access; approvals) and sends it once, in a layout effect, before any reader's effect runs. Each part is handed to the reader that used to ask for it, as that reader's read on the wire: `RelatedCountStore.seed` (tab badges, collapsed, compact and rail cards), `seedRecordVerdict`, `seedQueueClaimDecision`, `seedRecordFieldAccess`, `seedRecordApprovals`. A refused part reaches its reader as that read's refusal. No dual path: an adapter that implements `getRecordPage` never sends the replaced reads at open. - In a drawer, the open record's page prefetches the record a ↑ / ↓ step shows next (the drawer's `upcoming` record from #27636) through `DataSource.prefetchRecordPage` (at most 4 held, taken once, dropped by any write). See "Merge resolution with #27636" below. - **What still reads for itself, by design:** every reader after the open (a refresh, a write, a change on the data-invalidation bus); a list the page cannot name in the request (one declared through a `listView`, one whose filter carries a context token the reader resolves against the session first, one whose filter does not lower, the lists past the fiftieth); the attachments badge probe; a sorted list sharing a key with a rail card (the rail takes rows in the default order); and a list whose part the server refused: the store keeps no refusal, so a reader that mounts after the payload answered reads that list once more and gets the same refusal (measured in the harness: one extra request per refused list, never a count or a row). An adapter without `getRecordPage` reads exactly as before. ### Files outside the claim's file surface The claim named `RecordDetailView.tsx`, `recordDrawerStack.tsx`, `recordBinding.tsx`, `RelatedCardStack.tsx` and `ui/packages/data-objectstack` on the consumer side. Retiring the separate reads with no dual path meant each reader had to accept its part, so the diff also touches the readers themselves: `ui/packages/types` (the `DataSource` methods and shapes), `ui/packages/components` `related-count-store.ts` (`seed` / `takeRows`), `ui/packages/plugin-detail` (`useRecordEditable.ts`, `queueClaim.ts`, `recordFieldAccess.tsx`, `RelatedList.tsx`, `index.tsx`), `ui/packages/app-shell` `useRecordApprovals.ts` and the new `recordPagePayload.ts`, and the dogfood fixture and test. `recordBinding.tsx` and `RelatedCardStack.tsx` did not need to move. `packages/spec/contracts/**` does not exist; the shapes live in `packages/spec/src/api/record-page.zod.ts` beside the other REST shapes. `packages/objectql` did not move (each list's count rides its own `findData` with `count: true`). ## Contract review should-fix (review 6508117594 on `330e18be1`) | Item | What changed | Commit | Test | |:--|:--|:--|:--| | 1. A refused related list could read as an empty one | `RecordPageRelatedAnswerSchema` is now a union of two strict arms, `RecordPageRelatedRowsSchema` `{object, field, total, records}` and `RecordPageRelatedRefusalSchema` `{object, field, error}`, so an answer carries rows or a refusal, never both and never neither. The authorable shard drops the five `RecordPageRelatedAnswer:*` rows (they never shipped) and gains the two arms' rows. | `35a872079` | `record-page.test.ts`: both arms parse; a refusal with `total` and/or `records`, and an answer with neither, are refused. | | 2. The explain answer hand-listed its operations; its schema was private | The explain part's keys derive from `RecordPageExplainOperationSchema.options`; `RecordPageExplainAnswerSchema` is exported with its `z.input` and `Parsed` aliases (rows in `api-surface`, `export-origins`, `declaration-map`, `json-schema.manifest`). | `35a872079` | `record-page.test.ts`: the explain object's keys equal the enum's options, and every option's refusal parses. | | 3. A malformed declared filter answered as a 400 part inside a 200 | The route lowers every declared filter with `parseFilterAST` beside the `invalid_relationship` judgement, before the record read, and reuses the lowered filter for the list read. A refusal is a `400 VALIDATION_FAILED` at `related.N.filter` (`invalid_filter`). `related[].filter`'s description says so. | `5a7107f65` (route, test), `35a872079` (description) | `record-page-routes.test.ts`: a filter `parseFilterAST` refuses (`{ title: { $in: 'x' } }`) is a located 400; neither `getData` nor `findData` is called. | | 4. `approvals.requests` on a deployment with no approval service | Its description says it is empty there, as `lock` says `null`. | `35a872079` | `record-page-routes.test.ts` (existing): with no approval service the part is `{ requests: [], lock: null }`. | `Metadata-protocol: added` still holds for the final shape: the authorable shard gains rows and loses only rows this PR added. Every moved package stays `minor`. ## Contract re-review must-fix (reviews 6512166649 on `0efe2a4de` and 6513269907 on `392693230`) Each must-fix was a CI red on the head it was found on, and each landed with a test, or a typecheck, that fails without it. | Must-fix | What changed | Commit | Test | |:--|:--|:--|:--| | 1. The record read asks the same exposure gate as `GET /data/:object/:id` | `enforceApiAccess(req, res, p, environmentId, 'get', context)`: the route passes the context it already holds, as `crud-routes.ts` does since #27692 (the 5-argument call was TS2554, `Type Check · workspace`) | `392693230` | `record-page-routes.test.ts` "asks the object-exposure gate as GET /data/:object/:id does, with the caller's context (#27692)": the gate is asked once with `'get'` and the resolved context. Ablated: with the 5-argument call it fails. `@objectstack/rest` `typecheck` (tsc and `check:test-typecheck`) exits 0 | | 2. The related list read speaks the declared QueryAST | `query: { object, where, limit, count: true, orderBy? }`, typed against `FindDataRequest` with no cast; the wire aliases `filter` / `top` and the `as any` are gone. `REGISTRAR_QUERY_SLOT_FLOORS` gains `'record-page-routes.ts': 1` and the whole-package slot control goes 4 → 5 | `392693230` | `rest-server-canonical-query-ast.test.ts` (rest `test:repo`, the `Test Core (8/8)` red) passes; `record-page-routes.test.ts` pins the exact `findData` arguments in the new spelling. The engine folds `top` into `limit` and `filter` into `where`, so the dogfood comparison against the separate list read is unchanged | | 3. The dogfood test, the contract's end-to-end consumer, compiles against the two-arm related answer | `record-page.dogfood.test.ts` reads each related part through `rowsArm(i)` / `refusalArm(i)`, which narrow by `'total' in` / `'error' in` and fail the test on the wrong arm; no cast. Six TS2339 in `@objectstack/dogfood` `typecheck` (the `Type Check · workspace` red on `392693230`) are gone | `6f040ab5e` | `@objectstack/dogfood` `typecheck` exits 0; the dogfood trio exits 0 (the refused ledger list now has to be the refusal arm, and the two readable lists the rows arm) | The review's ① also named the `Type Check UI` red (`check:spec-symbols`): `@object-ui/types` hand-declared `RecordPageRelatedRequest`, `RecordPageRequest` and `RecordPagePartRefusal`, three names `@objectstack/spec/api` exports. `8ad440797` re-exports them from the spec and binds `RecordPageOperation` to the spec's `RecordPageExplainOperation`. `check:spec-symbols` exits 0, and so do the remaining Type Check UI legs (below). **Correction to r2.** r2 listed every locked suite and gate on `392693230` as exit 0, and they were, but `@objectstack/dogfood` `typecheck` was not among them: vitest runs the dogfood test without type-checking it, so the six TS2339 there (must-fix 3) reached CI. This revision runs `turbo run typecheck` over every package `turbo ls --filter='...[merge base]'` lists (73 packages, 142 tasks, the packages the PR touches and every dependent of them, `@objectstack/dogfood` included) and lists its exit code. **Correction to r1.** r1 said every derived gate exited 0 on `0efe2a4de`, and that was true of the 137 that `dispatch-gates` derives. CI then failed three required contexts that none of those 137 covers: `@objectstack/rest` `typecheck` (after the main merge), rest `test:repo` (my locked run had rest `test` only), and ui `check:spec-symbols`. This revision runs all three, plus the ui type-check legs, and reports each one's exit code. ## Merge with #28129 (record pane v4), main at `d70e94ae1` The merge queue dropped the PR at 14:17Z on a conflict with #28129. `49204ce58` merges `origin/main` at `277275aa4` through `scripts/pm/os-regen-merge.sh`; `f66a0104b` then merges main at `d70e94ae1` (9 more commits, no conflict) the same way. Main moved no generated shard in either range, so step 2 kept the branch's bytes; the full regen chain on the merged tree (`pnpm --filter @objectstack/spec build`, `gen:export-origins`, `gen:schema`, `gen:api-surface`, `check:generated --fix`, `gen:docs`, `gen:strictness-ledger`, `check:generated`) exits 0 and leaves the tree clean (`git status --porcelain`: 0 lines). **One conflicted hunk, the `./recordDrawerStack.js` import in `ui/packages/app-shell/src/views/RecordDetailView.tsx`.** This PR imports `RecordDrawerUpcomingContext` (and adds the `./recordPagePayload.js` import below it); main imports `useRecordPane`. Resolved as the union: one import of `PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, useRecordPane`, then this PR's `recordPagePayload` line. Every other file merged without a conflict (`recordDrawerStack.tsx`, `RelatedList.tsx`, `plugin-detail/src/index.tsx`, `data-objectstack`, `client`). **A shape change the text merge did not show, fixed in `722a37c5a`.** #28129 changed `RecordDrawerUpcomingContext` from one record (`RecordDrawerUpcoming | null`, the step in the last direction) to `readonly RecordDrawerUpcoming[]`, BOTH neighbours, the one in the last step's direction first. This PR's prefetch read `upcoming.objectName` / `upcoming.recordId`, which merged cleanly and is `undefined` on an array, so on the merged tree the pane would have asked no neighbour ahead. The fix asks each neighbour of the same object once this record's page has answered, keyed on the ids' content (AGENTS.md #10). The adapter's prefetch map holds up to 4 entries, so both fit. **v4 entry points checked.** - Split pane beside the list and the non-modal drawer over it: both are `ObjectViewRecordOverlay` → `RecordStackDrawer`, which renders the same embedded `RecordDetailView` inside `RecordDrawerUpcomingContext`. One page request per open. - `↑` `↓` and a click on another row (root swap): the level's `Fragment` key is depth + object, so the record swaps in place through `recordIdOverride`; a swap to a prefetched neighbour sends nothing for that record. - `⤢` maximize in place: `NavigationOverlay` keeps one element and restyles it; no remount, no re-ask. - 「Open as full page」 (record header) and the row's `↗`: the record route, the non-embedded view the cold and warm cases pin. **New test case** in `RecordDetailView.recordPagePayload-27627.test.tsx` ("in the v4 pane, both neighbours are asked ahead as pages, and an in-place step to either opens from that answer"): the pane opens `c2` with neighbours `[c3, c1]` → pages `c2`, `c3`, `c1` and no count probe for either neighbour; an in-place step to `c1` with neighbours `[c0, c2]` → only `c0` and `c2` are asked, nothing for `c1`, no non-page data request. The existing cases now hand the context an array. **Ablation:** prefetching only the first neighbour turns this case red (1 failed, 5 passed); restored, 6 passed. **Cost, stated.** Each step re-asks the record just left: the open took its prefetched answer, and the adapter keeps no settled payload (a kept one would serve rows a write has since changed). That is one background page request per step, sent after the current page has answered, matching v4's choice to warm both sides (its count warm-up also covers both neighbours). ## Merge with main at `ba4ecf1d2` (178 commits) `428642e58` merges `origin/main` through `scripts/pm/os-regen-merge.sh`; `780dd8c26` regenerates the three surface shards the os-regen driver merged without a text merge. Nothing else moved. **One conflict, `ui/packages/data-objectstack/src/index.ts`, two hunks.** Main's #27907 (objectstack#27626: concurrent `getRecordFieldAccess` asks share one request; 404 and 501 are remembered) and this PR each added adapter members in the same two places: 1. The fields beside `inflightFindOnes`: this PR's `inflightRecordPages` / `prefetchedRecordPages`, main's `inflightFieldAccess` / `unansweredFieldAccess` / `fieldAccessUnsupported`. Kept both, this PR's first. 2. `dropInflightReads`: this PR clears both record page maps (a payload carries other objects' rows, so any write drops it); main drops its field-access entries by the resource prefix. Kept both. Main's rewritten `getRecordFieldAccess` and this PR's `getRecordPage` / `prefetchRecordPage` / `readRecordPage` merged without a conflict. The two do not overlap: with the payload, the console seeds field access from the page and never calls `getRecordFieldAccess` on open, while after a save the field-access readers still go through it (now shared). **Generated shards.** The merge commit dropped main's three `disabledActions` authorable rows (the driver's silent one-sided merge); the regeneration restores them. Against main the three shards differ by exactly this PR's rows: `api-surface` +36, `authorable-surface` +30, `export-origins` +36. `check:generated` exits 0. ## Merge resolution with #27636 `origin/main` gained #27636 (objectstack#27056: the related column loads counts first, then rows only for the cards in view, and the drawer warms the next record's related counts). The merge (`7d6f3ed34`) conflicted in three files: - `ui/packages/app-shell/src/views/RecordDetailView.tsx` — the imports. The page now reads #27636's `RecordDrawerUpcomingContext` for its prefetch. - `ui/packages/app-shell/src/views/recordDrawerStack.tsx` — taken as main has it. This PR's own neighbours context (`RecordNeighborsContext`, previous and next sibling) is gone, so the drawer names one next record, #27636's `upcoming`. - `ui/packages/plugin-detail/src/RelatedList.tsx` — the fetch effect's dependency list, and a name collision. The compact branch reads the card context once (#27636's `compact`). The payload adoption still runs before #27636's in-view / non-zero gate (`rowsWanted`), so a card whose rows the payload carries draws them without reading, in view or not. What runs where: - **An adapter with `getRecordPage` / `prefetchRecordPage`** (the console's `ObjectStackAdapter`): the open asks one page payload, and #27636's count set (`useRelatedCounts` → `RelatedCountStore.fetch`) joins the seeded counts, so it sends nothing. In a drawer, the page prefetches the `upcoming` record's payload once this one's has answered, and #27636's `PrefetchUpcomingRelatedCounts` is not mounted. The next record's counts are asked once, inside its page, not twice. - **An adapter without them**: nothing seeds the store. #27636's count set, its in-view row reads and its drawer warm-up run unchanged. Tests, both in the acceptance file, on the real adapter in a drawer (`embedded`, `upcoming` = c2): - with the payload: the pages asked are exactly c1 and c2, and no count probe for c2 goes out. Ablated (the warm-up mounted regardless), this case fails. - without it (the adapter's two methods removed): no page request, and each of c2's four related counts is asked exactly once by the warm-up. #27636's own suites (`RecordDetailView.relatedCountsFirst-27056`, `__tests__/recordDrawerStack.prefetch-27056`, `useRelatedCounts-27056`, `RelatedList.compactLazy-27056`) pass unchanged on the merged head. ## Tests - `packages/rest/src/record-page-routes.test.ts`, 13 cases: the composed answer parses against `RecordPageResponseSchema` (expand, declared filter AND parent condition, multi-value `$contains`, `sort` → `orderBy`); only the named parts are asked; a record refusal is the whole answer; a related refusal carries `error` and no `total` / `records`; an API-disabled child is `OBJECT_API_DISABLED` 404 and never read; part refusals for field access, explain and approvals; missing services answer 501 parts and empty approvals; a system context without a user id gets a 401 explain part; `invalid_relationship` 400 before any read; a strict body refuses unknown keys and a bad sort order; a query parameter is a 400. - `packages/qa/dogfood/test/record-page.dogfood.test.ts`, 3 cases, **as a non-admin member** on a booted stack (fixture: an account with an FLS-unreadable `rating`; notes under owner RLS with an FLS-unreadable `secret`; tasks with a declared filter; a ledger object with no grant; a private object). Each part equals its separate read for the same member: the record equals `GET`; notes `total` 2 and the same rows with no `secret`; tasks with `{status:'open'}` `total` 4 and 3 rows; the ledger list is a 403 part with neither `total` nor `records`; field access equals `GET …/field-access`; explain equals `POST /security/explain`; approvals match; an unreadable or missing record's page equals the record read's 404; `invalid_relationship` and query-parameter 400s. - `ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx`, the card's acceptance test on the real adapter: a cold open sends exactly one page request and zero `top=1` probes, every data request in wave 1, and every badge total comes from the payload; a warm open sends one page request; the drawer's next record is asked ahead with the same body, and stepping to it sends nothing but the following record's prefetch; the two merge-resolution cases below; and the v4 pane case (both neighbours, in-place step), 6 cases in all. - `packages/spec/src/api/record-page.test.ts` (new, 3 cases) and one new case in `record-page-routes.test.ts`: the contract review's should-fixes, mapped below. - The rest censuses a new route owes: `rest-write-response-internal-fields.tripwire.test.ts` drives the route as a read over POST (the read-path strip of `internal: true` fields, like `POST /data/:object/query`); `rest-write-route-hook-refusal-sentence.ledger.test.ts` drives a `beforeFind` refusal of the record read as the whole answer; `record-page-routes.ts` joins `REST_SERVER_REGISTRAR_FILES`, so the `resolveExecCtx` census and the docblock-position floors read it. - Existing open-time pins updated to read the payload's body for the questions the separate reads used to ask (`recordOpenRequests-11699`, `recordOpenRequests-12701`, `recordOpenWaves-13010`, `afterActionExplain-13341`, `recordReadFailure-11902`): their assertions on what is asked, how often and in which wave are kept. ## Verification Exit codes are the verdict; counts are context. <details><summary>Locked suites (os-verify-lock), each part with its own exit code</summary> On `f66a0104b` (main `d70e94ae1` merged, the v4 prefetch fix): | Suite | Exit | Counts | |:--|--:|:--| | spec build, then the regen chain above | 0 / 0 | tree clean after it | | `turbo run build` (whole repo) | 0 | 116 tasks | | ui `packages/data-objectstack` | 0 | 106 files, 1236 passed | | ui `packages/plugin-detail` + `packages/app-shell` (whole package, #28129's tests included) | 0 | 2318 files, 20795 passed, 27 skipped | | `pnpm --dir ui run check:spec-symbols` | 0 | | | ui `turbo run type-check` over CI's affected filter since `d70e94ae1` | 0 | 107 tasks | | `@objectstack/rest` `typecheck` / `test` / `test:repo` | 0 / 0 / 0 | 354 files, 6829 passed / 5 files, 284 passed | | `turbo run typecheck` over the 74 packages that depend on the merge base's changes (`packages/*`, `apps/*`) | 0 | 144 tasks | | dogfood `record-page` + `authz-probe-blind-spot` + `authz-conformance` | 0 | 3 files, 73 passed | On `780dd8c26` (main merged, artifacts regenerated): | Suite | Exit | Counts | |:--|--:|:--| | `turbo run build` (whole repo, after `pnpm install` for main's new `driver-odata`) | 0 | 116 tasks | | ui `packages/data-objectstack` | 0 | 105 files, 1234 passed | | ui `packages/plugin-detail` + `app-shell` `RecordDetailView*` + `recordDrawerStack` | 0 | 430 files, 3655 passed, 18 skipped | | `pnpm --dir ui run check:spec-symbols` | 0 | | | ui `turbo run type-check` over CI's affected filter since the merge base | 0 | 107 tasks | | `@objectstack/rest` `typecheck` / `test` / `test:repo` | 0 / 0 / 0 | 354 files, 6825 passed / 5 files, 284 passed | | `turbo run typecheck` over the 74 packages that depend on the merge base's changes (`packages/*`, `apps/*`) | 0 | 144 tasks | | dogfood `record-page` + `authz-probe-blind-spot` + `authz-conformance` | 0 | 3 files, 73 passed | | `spec` `check:generated` | 0 | | On `6f040ab5e` (must-fix 3): | Suite | Exit | Counts | |:--|--:|:--| | `turbo run typecheck` over the 73 workspace packages the PR touches or that depend on one it touches (`turbo ls --filter='...[merge base]'`, `packages/*` and `apps/*`) | 0 | 142 tasks | | `@objectstack/dogfood` `typecheck` | 0 | | | dogfood `record-page` + `authz-probe-blind-spot` + `authz-conformance` | 0 | 3 files, 73 passed | On `392693230` (must-fixes 1 and 2): | Suite | Exit | Counts | |:--|--:|:--| | `@objectstack/rest` `typecheck` (`tsc --noEmit` and `check:test-typecheck`) | 0 | | | `@objectstack/rest` `test` | 0 | 343 files, 6720 passed, 326 skipped | | `@objectstack/rest` `test:repo` | 0 | 5 files, 284 passed, 19 skipped | | `@objectstack/rest` build, then dogfood `record-page` + `authz-probe-blind-spot` + `authz-conformance` | 0 / 0 | 3 files, 73 passed | | ui scripts suite (`pnpm --dir ui exec vitest run scripts/`) | 0 | 169 files, 4902 passed | On `8ad440797` (the ui types fix; no ui file moved after it), the Type Check UI legs: | Leg | Exit | |:--|--:| | `pnpm --dir ui run check:spec-symbols` | 0 | | `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:side-effects-array` | 0 each | | `turbo run type-check` over the ui packages changed since the merge base and their dependents (CI's filter; 106 tasks) | 0 | | `type-check:scripts`, `type-check:e2e`, `type-check:vitest-setup`, `type-check:coverage` | 0 each | Carried from r1, on `0efe2a4de` (no file these cover moved since): | Suite | Exit | Counts | |:--|--:|:--| | `@object-ui/app-shell` `tsc --noEmit` | 0 | | | ui `packages/app-shell` | 0 | 1910 files, 17272 passed, 9 skipped | | ui `packages/plugin-detail` + `components/src/hooks` + `data-objectstack` + `types` | 0 | 824 files, 14724 passed, 18 skipped | | `@objectstack/client` | 0 | 52 files, 680 passed | | `@objectstack/spec` / `test:repo` | 0 / 0 | 754 files, 22394 passed / 87 files, 1448 passed | | ui `RecordDetailView*` + `recordDrawerStack` tests | 0 | 85 files, 548 passed | </details> **Correction to r0.** r0 reported the app-shell suite green. That run exited 1: every test passed, but vitest counted three unhandled rejections from `afterActionExplain-13341`, raised by a seeded approvals part that no reader joined. Fixed in `367cfd2ad`; the app-shell run above exits 0. - `dispatch-gates --commands` on `f66a0104b`: 137 derived; `--ran`: 137 run, 0 NOT-MEASURED, 0 UNRUN, every gate exit 0; `--ran` itself exit 0 (resumed from an append-only record after a container restart at gate 45). None of the 137 is `check:spec-symbols`, a package `typecheck` or rest `test:repo`; those are run and listed above. - `check-changeset-no-major --base origin/main --event` with this body: exit 0 (declaration `Clause-②: yes (widening)`, `Metadata-protocol: added`) - Size: 3193 additions + 103 deletions = 3296 changed lines (`git diff --shortstat origin/main...HEAD`, 48 files), up from 3257 by the v4 fix. Over the 3000 threshold, so this PR is Tier H and needs a maintainer approval. - Head `f66a0104b6d18598f4132bcc629f66d551d0a244`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99a6a78 commit 0027c4a

48 files changed

Lines changed: 3193 additions & 103 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/data-objectstack': minor
4+
'@object-ui/components': minor
5+
'@object-ui/plugin-detail': minor
6+
---
7+
8+
feat(console): a record page opens from one request, and the record a drawer step shows next is asked ahead (#27627)
9+
10+
Clause-②: yes (widening: optional `DataSource.getRecordPage` / `prefetchRecordPage`, new exports; no existing contract changes)
11+
Metadata-protocol: added
12+
13+
The record page (full page, split pane and drawer) asks `DataSource.getRecordPage` once when it opens, before any of its readers runs, and hands each part of the answer to the reader that used to ask for it:
14+
15+
- the record, with the lookups the page resolves;
16+
- each related list's total and first rows: the tab badges, collapsed and compact related cards and reference rail cards take them, so no `top=1` count probe and no separate first-rows read goes out;
17+
- the header's `update` / `delete` verdicts and Claim's `transfer` decision, the in-place edit blocks' field projection, and the approval rows and lock.
18+
19+
Readers still read for themselves after the open (a refresh, a write, a change on the data-invalidation bus), and a page on an adapter without `getRecordPage` reads as before. Lists the page cannot name in the request stay their own read: a list declared through a list view, a list whose filter carries a context token, and lists past the fiftieth. In the drawer, the record a ↑ / ↓ step shows next is asked ahead (`DataSource.prefetchRecordPage`), so stepping opens from an answer already on its way; on such an adapter that replaces the drawer's warm-up of the next record's related counts.
20+
21+
- `@object-ui/types`: `DataSource.getRecordPage?` / `prefetchRecordPage?` and the `RecordPageRequest` / `RecordPagePayload` shapes.
22+
- `@object-ui/data-objectstack`: both methods; concurrent identical reads share one request, a prefetched answer is taken once, and a write drops them all.
23+
- `@object-ui/components`: `RelatedCountStore.seed` / `takeRows`.
24+
- `@object-ui/plugin-detail`: `seedRecordVerdict`, `seedRecordFieldAccess`, `seedQueueClaimDecision`, `normalizeRelatedListSort`, `COMPACT_RELATED_ROWS`; a compact related card takes its first rows from the record page's answer. Its `sideEffects` array now names `queueClaim` and `recordFieldAccess` too: each drops a seeded answer on a data change through a bus subscription made at load.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/client': minor
5+
---
6+
7+
feat(rest): `POST /api/v1/data/{object}/{id}/page` answers everything a record page asks when it opens, in one request (#27627)
8+
9+
Clause-②: yes (widening: a new route and its request and response shapes; no existing route, key or answer changes)
10+
Metadata-protocol: added
11+
12+
A record page used to open in two or three stages of requests: the record, the approval list, the access verdicts and each related list's definition first, then the approval lock and one count probe per related list. The new route answers all of it from one request:
13+
14+
- **The request names the parts** (`RecordPageRequestSchema`, `@objectstack/spec/api`): `expand` (the record's lookups to resolve), `related` (each related list the page draws: the child object, its relationship field to this record, an optional declared `filter`, `top` first rows, an optional `sort`), `explain` (`update` / `delete` / `transfer`), `fieldAccess` and `approvals`. The body is strict and the query-parameter set is closed and empty.
15+
- **Every part is the read it replaces, as the same caller** (`RecordPageResponseSchema`): the record is `GET /data/{object}/{id}`; a related list is `GET /data/{child}` with the parent condition AND the declared filter, and `$count`; `fieldAccess` is `GET /data/{object}/{id}/field-access`; a verdict is `POST /security/explain` with the record id; `approvals` is `GET /approvals/requests` for the record (the pending row enriched as `GET /approvals/requests/{id}`) and the record lock. Object permissions, row-level security, sharing and field-level security therefore apply exactly as they apply to those reads.
16+
- **A refusal stays where it belongs.** A record the caller cannot read refuses the whole request with the record read's own answer. A part whose read refuses carries that refusal (`{ code, status, message }`) in place of its answer, and the other parts are answered; a related list the caller may not read carries neither a count nor rows.
17+
- **A related entry must be a relationship to this record**: a lookup or master-detail field of the child that references the page's object, or the request is a located `400` before anything is read.
18+
- **Server-Timing**: the route's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin).
19+
- `@objectstack/client` gains `data.recordPage(object, id, request)`.

‎content/docs/references/api/index.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: API Protocol — complete schema reference
33
navTitle: API Protocol
4-
description: "The ObjectStack API Protocol in 32 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
4+
description: "The ObjectStack API Protocol in 33 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -35,6 +35,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
3535
<Card href="/docs/references/api/query-adapter" title="Query Adapter" description="Source: packages/spec/src/api/query-adapter.zod.ts" />
3636
<Card href="/docs/references/api/realtime" title="Realtime" description="Source: packages/spec/src/api/realtime.zod.ts" />
3737
<Card href="/docs/references/api/realtime-shared" title="Realtime Shared" description="Source: packages/spec/src/api/realtime-shared.zod.ts" />
38+
<Card href="/docs/references/api/record-page" title="Record Page" description="Source: packages/spec/src/api/record-page.zod.ts" />
3839
<Card href="/docs/references/api/rest-server" title="Rest Server" description="Source: packages/spec/src/api/rest-server.zod.ts" />
3940
<Card href="/docs/references/api/router" title="Router" description="Source: packages/spec/src/api/router.zod.ts" />
4041
<Card href="/docs/references/api/sortability" title="Sortability" description="Source: packages/spec/src/api/sortability.zod.ts" />

‎content/docs/references/api/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"misc",
3737
"package-api-assembled",
3838
"package-lifecycle",
39+
"record-page",
3940
"sortability"
4041
]
4142
}

0 commit comments

Comments
 (0)