Repository navigation
Commit e7a6cc7
feat(rest,console): a record open is one request; the page payload carries the record's streams and the related rows' names (#28682)
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbBLJwakjtd38ozmWiE7HN9i)_
Fixes #28470
## 维护者速读
- **改了什么**:打开一条记录时,评论、动态、附件和关联行里引用记录的名称,都跟着 `POST
/data/:object/:id/page` 一起返回;@
提及候选人(`sys_user?top=50`)改为评论框第一次获得焦点时才读。打开记录不再单独发这些请求,也没有回退读取。
- **为什么改**:在 app-crm 的生产构建上实测,打开一个联系人除了 `/page` 之外还会发
`sys_comment`、`sys_activity`、`sys_user?top=50`,以及每个被引用对象一次 `?top=1`
名称查询,共 8 个多余请求。
- **风险与回滚**:协议只增不减(Clause-② 加宽,Metadata-protocol:
added,`@objectstack/spec`
minor),新字段全部可选,旧客户端不受影响;每个新部件单独拒绝,不会连累整页。回滚就是还原本 PR。动态部件一次取 200
条,这是已命名的偏差,见下文。
-
**先看**:`packages/spec/src/api/record-page.zod.ts`(新成员和流表说明)、`packages/rest/src/record-page-routes.ts`(每个部件就是它替代的那次读)、`ui/packages/app-shell/src/views/RecordDetailView.tsx`(何时请求各部件);文档
`content/docs/frontend/plugins/plugin-detail.mdx` 的 Console Integration
一节改为讲记录页请求。
- **两分钟验证**:`cd ui && pnpm exec vitest run
packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx`,五条通过:一次
`/page`,零次评论、动态、附件、提及候选和 `?top=1` 读取,含两条消融。
- **需要你做的**:总监做合约审查(CONTRACT_REVIEW_TIER)。声明行已按席位修订的认领改为
`Metadata-protocol: added`,`@objectstack/spec` 定为 minor。PR 保持草稿。
## What changes
One record open is one request. The parts a record page still read on
its own after the page payload answered now ride in `POST
/api/v1/data/{object}/{id}/page`, and the console takes them from there
with no fallback read.
### Contract members added (one complete list for the contract review)
`@objectstack/spec` (`packages/spec/src/api/record-page.zod.ts`,
exported from `@objectstack/spec/api`):
Request (`RecordPageRequestSchema`, still `.strict()`):
- `related[].expand?: string[]`: the lookups of a related list's rows to
resolve, as that list read's own `$expand`.
- `discussion?: boolean`: ask for the record's comment thread and its
reactions.
- `activity?: { top: int 1..200 }` (`RecordPageActivityRequestSchema`,
strict): ask for the newest activity rows and their count.
- `attachments?: { top: int 0..100 }`
(`RecordPageAttachmentsRequestSchema`, strict): ask for the newest
attachments and their count.
Response (`RecordPageResponseSchema`):
- `discussion?: { comments, reactions } | { error }`
(`RecordPageDiscussionSchema` | `RecordPageStreamRefusalSchema`).
`reactions` is `{ records } | { error }`
(`RecordPageDiscussionReactionsSchema`), so a refused reaction read
leaves the comments answered.
- `activity?: { total, records } | { error }`
(`RecordPageStreamRowsSchema` | `RecordPageStreamRefusalSchema`).
- `attachments?: { total, records } | { error }` (same pair).
New exports: `RecordPageActivityRequestSchema`,
`RecordPageAttachmentsRequestSchema`, `RecordPageStreamRefusalSchema`,
`RecordPageStreamRowsSchema`, `RecordPageDiscussionReactionsSchema`,
`RecordPageDiscussionSchema`, `RECORD_PAGE_MAX_ACTIVITY_ROWS` (200),
`RECORD_PAGE_MAX_ATTACHMENT_ROWS` (100), and for each schema its `X`
(input) and `XParsed` (output) type.
`@object-ui/types` (`ui/packages/types/src/data.ts`, published):
- `RecordPagePayload.discussion?: { comments: Record<string, any>[];
reactions: { records: Record<string, any>[] } | { error:
RecordPagePartRefusal } } | { error: RecordPagePartRefusal }`
- `RecordPagePayload.activity?: { total: number; records: Record<string,
any>[] } | { error: RecordPagePartRefusal }`
- `RecordPagePayload.attachments?: { total: number; records:
Record<string, any>[] } | { error: RecordPagePartRefusal }`
- The request type is the spec's `RecordPageRequest`, re-exported, so it
gains the request members above with no separate declaration.
`@object-ui/react`: `DiscussionContextValue.onMentionSuggestionsWanted?:
() => void`.
`@object-ui/plugin-detail`: `RichTextCommentInput`,
`RecordActivityTimeline` and `RecordChatterPanel` take
`onMentionSuggestionsWanted?: () => void`, called on the editor's focus.
`record:activity` and `record:chatter` pass the context's hook.
### What each new part answers
Each part is the read it replaces, asked as the same caller, behind the
stream object's own API exposure gate, and refused on its own:
| Part | The read it replaces |
| --- | --- |
| `related[].expand` | the related list read's own `$expand` (`GET
/data/{child}?$expand=…`), spelled as the QueryAST relation map that
read's normalizer lowers it to |
| `discussion.comments` | `GET /data/sys_comment` where `thread_id` =
`{object}:{id}`, oldest first, no page size |
| `discussion.reactions` | `GET /data/sys_comment_reaction` where
`comment_id` in those comments, oldest first; not asked when the thread
is empty |
| `activity` | `GET /data/sys_activity` for the record, newest first,
`$top`, `$count`, `$expand=actor_id` |
| `attachments` | `GET /data/sys_attachment` for the record, newest
first, `$top`, `$count` |
A record the caller cannot read still refuses the whole request, its
thread with it. The body stays strict and the query-parameter set stays
empty.
### Console
- The page asks `discussion` and `activity` when a block that draws the
feed is on screen at open (the 动态 panel of a laid-out page, or a feed
block on the tab the page opens on), and `attachments` when the page
draws them. The feed, the Attachments panel and the Attachments tab
badge draw those parts. A refused part draws the refusal the panel draws
today.
- The stream asks ride whichever payload the plan asks and never key it,
so an assigned page landing after a synthesized one asks nothing again
(objectui#11699 stays green).
- Each related list asks the lookups of its rows to resolve (every
declared reference of the child but the parent's, gated by field-level
security), so the rows carry their names.
- `RelatedList`'s name fallback no longer resolves the parent record's
own id, which no column draws.
- The mention roster (`sys_user`, 50 rows) is read on the composer's
first focus. A reply or an edit whose text holds an `@` before then
reads it when written, so its mentions still resolve.
### Docs
- `content/docs/frontend/plugins/plugin-detail.mdx`, the "Console
Integration" section only (added to the file surface by claim
amendment). It showed the console reading a record's comments with
`dataSource.find('sys_comment', …)`, with field names already stale on
`main`. It now says a record open takes the discussion and the activity
from the `/page` payload, and names when the console still reads
`sys_comment` itself (a feed the open did not ask for, or a data source
without `getRecordPage`) and when it reads the mention list. Its snippet
calls `getRecordPage` with the real types. The rest of the page is
unchanged.
### The `?top=1` probe caller
`RelatedList`'s reference-name fallback
(`ui/packages/plugin-detail/src/RelatedList.tsx`) resolving the
payload's bare ids in related rows, one `GET
/data/{object}?top=1&filter=["id","in",[…]]` per referenced object, the
parent FK itself among them. The rows now arrive expanded, and the
parent FK is skipped.
### Named deviation: the activity part asks 200 rows
The 动态 panel shows 3 or 5 rows, but its count chip and the 动态 tab count
the whole merged stream (comments + activity + field changes, minus
dropped types). A 5-row window would make that count wrong, so the part
asks the same 200-row window the console's own read asked before.
Shrinking it needs a server-side count of the merged stream: see
`out_of_scope_findings`.
Follow-up: #28737 serves the merged 动态 count with the page payload, so
the activity part can ask the panel's few rows. It owns
`RECORD_PAGE_MAX_ACTIVITY_ROWS`.
### `/page` once per open (card item 4)
Measured on a production build before this change: `/page` goes out once
per record. The two other `/page` posts in a drawer open are the
previous/next prefetch (#27056, #27545), different ids, by design. So
item 4 changes nothing, and the seat recorded it out of scope.
### App-level reads a record open causes (out of scope, left to #28085 /
#27626)
- In a drawer open: `GET /api/v1/meta/object` (the object list read
again on open) and `PATCH /api/v1/data/sys_user_preference/{id}` (the
recent-items write). Neither is the record's, and neither is changed
here.
- On the record route, the other 26 requests are the shell's own boot
(runtime config, session, discovery, translations, organizations, auth
config, the preference read, `meta/app`, `meta/view`, `meta/page`,
`meta/object`, `meta/dashboard`, the app bundle, packages, permissions,
localization, settings, AI agents, the approvals inbox,
`sys_inbox_message`, `sys_user/{me}` and `meta/object/sys_user`) plus
the same preference write. A record open does not cause them.
## Before / after
Measured on `examples/app-crm`, production-built Console (`pnpm build &&
pnpm console:build`, `pnpm dev:crm -- --fresh --ui --no-watch`),
Playwright, signed in as admin, every `/api/` request from the click
(drawer) or navigation (record route) until network idle plus 3 s.
HotCRM was not available, so app-crm stands in.
**Contact drawer** (open "Ada Lovelace" from the Contacts list): 13
requests before, 4 after.
```
before after
1. GET /meta/object 1. GET /meta/object
2. POST /data/crm_contact/{ada}/page 2. POST /data/crm_contact/{ada}/page
3. GET /data/sys_user?top=50&select=id,name,email,image (read on the composer's first focus)
4. GET /data/sys_comment?sort=created_at&filter=thread_id = crm_contact:{ada} (in /page: discussion)
5. GET /data/sys_activity?populate=actor_id&top=200&sort=timestamp&… (in /page: activity)
6. POST /data/crm_contact/{prev}/page neighbour prefetch 3. POST /data/crm_contact/{other}/page neighbour prefetch
7. POST /data/crm_contact/{next}/page neighbour prefetch
8. GET /data/sys_organization?top=1&filter=id in [...] (related rows arrive expanded)
9. GET /data/sys_user?top=1&filter=id in [...] (related rows arrive expanded)
10. GET /data/crm_contact?top=1&filter=id in [{ada}] (the parent FK: no longer resolved)
11. GET /data/crm_account?top=1&filter=id in [...] (related rows arrive expanded)
12. GET /data/crm_opportunity?top=1&filter=id in [...] (related rows arrive expanded)
13. PATCH /data/sys_user_preference/{id} 4. PATCH /data/sys_user_preference/{id}
```
The after run has one neighbour prefetch instead of two only because the
fresh seed listed Ada third of three (no next row); the prefetch is
unchanged.
**Account drawer** (open "Acme Corp"): 13 requests before, 5 after: `GET
/meta/object`, `POST /data/crm_account/{acme}/page`, the two neighbour
prefetches, `PATCH /data/sys_user_preference/{id}`. Gone:
`sys_user?top=50`, `sys_comment`, `sys_activity`, and the five `?top=1`
probes (`sys_organization`, `sys_user`, `crm_contact`, `crm_account`,
`crm_opportunity`). The page request body:
```json
{"expand":["organization_id","created_by","updated_by","owner_id","owning_business_unit_id"],"related":[{"object":"crm_activity","field":"account","top":3,"expand":["organization_id","created_by","updated_by","owner_id","owning_business_unit_id","contact","opportunity"]},{"object":"crm_contact","field":"account","top":3,"expand":["organization_id","created_by","updated_by","owner_id","owning_business_unit_id"]},{"object":"crm_lead","field":"account","top":3,"expand":["organization_id","created_by","updated_by","owner_id","owning_business_unit_id","converted_opportunity"]},{"object":"crm_opportunity","field":"account","top":3,"expand":["organization_id","created_by","updated_by","owner_id","owning_business_unit_id","renewal_of"]}],"explain":["update","delete","transfer"],"fieldAccess":true,"approvals":true,"discussion":true,"activity":{"top":200}}
```
**Contact record route** (navigate to
`/apps/crm_app/crm_contact/record/{ada}`, shell boot included): 37
requests before, 28 after. The nine gone: `sys_user?top=50`,
`sys_comment`, `sys_activity`, the five `?top=1` probes, and `GET
/meta/object/sys_organization` (the schema read the name fallback made
for the `sys_organization` probe). Nothing was added.
**Composer focus** (account drawer, then a click into "Write a note… @
to mention"): one `GET
/data/sys_user?top=50&select=id,name,email,image`, at the focus, and
none before it.
App-crm does not enable files on these objects, so `attachments` is not
asked here; the console test and the dogfood suite cover that part.
## Tests
- `packages/spec/src/api/record-page.test.ts`: request strictness for
the new members (unknown key in `activity` / `attachments` refused,
bounds), and each stream part is its rows or its refusal, never both and
never neither.
- `packages/rest/src/record-page-routes.test.ts` (7 new, 20 total): each
part's read as replaced, for the same caller; an empty thread asks no
reaction read; per-part refusal (a refused activity read leaves the
others answered); a refused comment read; a refused reaction read keeps
the comments; the stream object's exposure gate; strict body.
- `packages/qa/dogfood/test/record-page-streams.dogfood.test.ts` (new,
real HTTP + security stack, non-admin member): discussion, reactions and
activity equal the member's own `GET` reads byte for byte, including a
`top: 1` window with the whole count; a private record refuses the whole
request as `GET` does; attachments count and list only the record's own
(3), never another record's (2 secret).
- `packages/qa/dogfood/test/record-page.dogfood.test.ts`: a related
list's `expand` is its read's own `$expand`.
-
`ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx`
(new, real `ObjectStackAdapter`, StrictMode): one `/page`; zero
`sys_comment`, `sys_comment_reaction`, `sys_activity`, `sys_attachment`,
`sys_user?top=50` and `?top=1` reads; the body asks `discussion`,
`activity` 200, `attachments` 100 and the related `expand`; the feed,
the attachment and the badge draw the payload; the mention read goes out
once, on the composer's focus; a refused activity draws
`data-denied-source="activity"` and is not read again. Ablations: with
no `getRecordPage` the detectors see the `sys_activity`,
`sys_attachment` and `sys_comment` reads; with the related `expand`
ignored they see the `?top=1` name probe. Removing the parent-FK skip
was checked to go red, then restored.
- Updated to the payload behavior:
`RecordDetailView.recordPagePayload-27627`,
`RecordDetailView.recordOpenWaves-13010`,
`RecordDetailView.withheldMentions-28218` (the composer reads the roster
on focus; a reply reads it at the write).
-
`ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx`:
it pinned the related timeline's label read of its parent `request_id`,
which the related `expand` retires. It now asserts no read of the parent
request by id, and still that no read the page makes is refused.
## Acceptance notes
- Contract tests: each new part answers the read it replaces for the
same caller (route unit tests + dogfood as a member); a refused part is
refused on its own; the body is strict.
- Console test: one `/page`, zero `sys_comment`, `sys_activity`,
`sys_attachment`, `sys_user?top=50` and `?top=1` reads, with ablations.
- Before/after request lists: above.
- Preview check is the epic seat's.
## Deviations
- **The activity part asks 200 rows**, as named above.
- **The discussion part is the whole thread, with no page size.** The
card asked for the discussion's first page. The part answers the read it
replaces: on `main` the console read the whole thread (`sys_comment`
where `thread_id` is `{object}:{id}`, oldest first, no `$top`) and held
all of it, and the comment thread has no paging door today. A page size
needs that door first, which is outside this card.
## out_of_scope_findings
- **class b, reach: every record page with a 动态 panel or tab** — a
server-side count of the merged 动态 stream (comments + activity + field
changes, minus the types the panel drops), so the activity part can ask
the panel's 3 or 5 rows instead of 200 and the count chip still holds.
Today the count is the client's, over the rows it holds, which is why
the part asks 200.
## Verification
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 108 commands, all exit 0 without a PR body
(`check-plugin-teardown-shape --self-test` after fetching its pinned
fixture commit on this shallow clone; `pnpm --dir ui exec vitest run
scripts/` rerun alone after two 15 s timeouts under a concurrent spec
suite: 169 files passed).
- `@objectstack/spec`: `test` 782 files / 23126 tests passed;
`test:repo` 87 files / 1448 passed; `check:generated` current after the
merge of main.
- `@objectstack/rest`: `typecheck` (incl. test typecheck) 0;
`record-page-routes.test.ts` 20 passed.
- `packages/qa/dogfood`: `typecheck` 0; the two record page suites 8
passed against the rebuilt rest dist.
- ui: 330 test files that import the touched modules, all passed
(`--maxWorkers=2` under the verify lock, the record-open files rerun
after their updates); `type-check` 0 for `@object-ui/types`,
`@object-ui/react`, `@object-ui/plugin-detail`, `@object-ui/app-shell`;
eslint 0 errors on every touched file (root config for the platform
files); `node scripts/check-sdui-manifest.mjs` 0.
- Merged `origin/main` (no text conflicts; generated spec artifacts
regenerated, `check-regen-pending` clear). At the merged head: the five
record-open ui files (42), the route suite (20) and
`record-page.test.ts` (7) rerun green.
- Declaration lines per the amended claim (`Metadata-protocol: added`,
`@objectstack/spec` at `minor` in
`.changeset/28470-record-page-streams.md`, head `579bcda1`). PR-body
gates with this body as `GITHUB_EVENT_PATH`: `check-changeset-no-major
--base origin/main` 0 (level axis: widening accounted for at `minor`),
`check-adr-0087-registration` 0, `check-closing-keyword-parity` 0,
`check-empty-changeset` 0, `check-partof-closing-keyword` 0; `pnpm
check:changeset-fixed` 0.
- Two CI reds at `579bcda1`, fixed at `2369096b`. CI's Test Core 8/8
runs rest's `test:repo`, whose query-slot census
(`rest-server-canonical-query-ast.test.ts`) counts every `query:` token
in a REST registrar: the stream read's parameter was spelled `query`, so
it is renamed and `record-page-routes.ts` is pinned at 2 slots (total 5
→ 6). CI's Test UI 9/16 ran the console served-page test above. At
`2369096b`: rest `test` and `test:repo` green, the 26 console test files
that touch the record page green.
- CI at `2369096b`: 65 check runs, 58 success, 7 skipped, none failed;
Check Changeset green.
- Contract review PASS at `f2e04ba8` (no must-fix). Its should-fixes:
`@objectstack/rest` is graded `minor` in
`.changeset/28470-record-page-streams.md` at `3b6fec52`, as AGENTS.md's
Post-Task Checklist step 3 reads ("a kind takes at least `minor` on a
package the diff moves") and as the door's first changeset graded it
(spec and rest share one fixed group, so the released version is
unchanged; `check-changeset-no-major --base origin/main`,
`check:changeset-fixed`, `check-empty-changeset` 0); the whole-thread
discussion is named under Deviations; the 200-row cap's follow-up card
is #28737.
- Docs fix at `f2e04ba8`: the section's `tsx` snippet type-checks
against `@object-ui/types` (`tsc` on the extracted fence, 0; a wrong
`activity.top` type in it goes red, TS2322). The docs gates the derived
list names, all exit 0: `check-docs-section-name` (and `--self-test`),
`check-affected-docs`, `check-drift-comment`, `check-doc-route-spelling
--advisory`, `check-doc-authoring`, `check:docs-audit-scope`,
`check:docs-redirects`, `check:docs-single-h1`,
`check:docs-spec-enumerations`, `check:docs-transcript-drift`,
`check:issue-citations`, `check:role-word`, `check:nul-bytes`,
`check:org-identifier`.
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — no file under `skills/**` teaches the record page
payload (`POST /data/{object}/{id}/page`), its request or response
members, or when the console reads the mention roster (a `git grep` of
`skills/` for `RecordPage`, `getRecordPage`, `sys_comment`,
`sys_attachment` and `mentionSuggestions` names nothing).
`skills/objectstack-data` teaches `enable.activities: false` hiding the
timeline and the detail-page related lists by relationship flags; both
stay true.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a314318 commit e7a6cc7
34 files changed
Lines changed: 1860 additions & 169 deletions
File tree
- .changeset
- content/docs
- frontend/plugins
- references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- qa/dogfood/test
- rest/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src/api
- ui
- apps/console/src/services
- packages
- app-shell/src/views
- plugin-detail/src
- renderers
- react/src/context
- types/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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
103 | | - | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
104 | 109 | | |
105 | 110 | | |
106 | 111 | | |
| |||
110 | 115 | | |
111 | 116 | | |
112 | 117 | | |
113 | | - | |
114 | | - | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | | - | |
119 | | - | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
120 | 122 | | |
121 | 123 | | |
122 | | - | |
123 | | - | |
124 | | - | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
125 | 131 | | |
126 | | - | |
| 132 | + | |
127 | 133 | | |
128 | | - | |
129 | | - | |
| 134 | + | |
130 | 135 | | |
131 | 136 | | |
132 | 137 | | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
133 | 145 | | |
134 | 146 | | |
135 | 147 | | |
| |||
0 commit comments