From 102c752c5a83a630572d34b90a660bff97e3e75a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 11:26:46 +0000 Subject: [PATCH 1/7] feat(spec,rest): the record page payload carries the record's streams and the related rows' names The page names a related list's expand and asks for the discussion, the activity and the attachments; the route answers each as the read it replaces, behind that object's own exposure gate, refused on its own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- .../test/record-page-streams.dogfood.test.ts | 210 ++++++++++++++++++ .../dogfood/test/record-page.dogfood.test.ts | 14 ++ packages/rest/src/record-page-routes.test.ts | 194 +++++++++++++++- packages/rest/src/record-page-routes.ts | 113 +++++++++- packages/spec/src/api/record-page.test.ts | 72 ++++++ packages/spec/src/api/record-page.zod.ts | 142 +++++++++++- 6 files changed, 724 insertions(+), 21 deletions(-) create mode 100644 packages/qa/dogfood/test/record-page-streams.dogfood.test.ts diff --git a/packages/qa/dogfood/test/record-page-streams.dogfood.test.ts b/packages/qa/dogfood/test/record-page-streams.dogfood.test.ts new file mode 100644 index 00000000000..97bf554107c --- /dev/null +++ b/packages/qa/dogfood/test/record-page-streams.dogfood.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28470] The record page payload's stream parts (`POST /data/:object/:id/page` +// with `discussion`, `activity` and `attachments`) answer a member who is not +// an admin exactly what the separate reads they replace answer that member, +// through the real HTTP + security stack: +// +// - `discussion` is `GET /data/sys_comment` for the record's thread (no page +// size, oldest first), plus `GET /data/sys_comment_reaction` for those +// comments (oldest first); +// - `activity` is `GET /data/sys_activity` for the record, newest first, with +// `$top`, `$count` and the actor expanded; +// - `attachments` is `GET /data/sys_attachment` for the record, newest first, +// with `$top` and `$count` โ€” the count inherits the parent's row-level +// security, so another record's attachments are neither counted nor listed; +// - a related list's `expand` is its read's own `$expand`. +// +// The stream comparisons are byte-for-byte against the member's own reads, so +// whatever the comment, activity and attachment read gates do to a row, the +// part does the same. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { RecordPageResponseSchema } from '@objectstack/spec/api'; +import { commentsFixtureStack, commentsFixtureSecurity } from './fixtures/comments-fixture.js'; +import { + bootAttachmentsHarness, + stopAttachmentsHarness, + uploadFile, + FILE_BYTES, + type AttachmentsHarness, +} from './fixtures/attachments-authz-harness.js'; + +const SYS = { isSystem: true } as const; + +type Answer = { status: number; body: any }; +const rowsOf = (body: any) => body?.data?.records ?? body?.records ?? body?.data ?? []; +const totalOf = (body: any) => body?.data?.total ?? body?.total; +const q = (value: unknown) => encodeURIComponent(JSON.stringify(value)); + +describe('[#28470] the record page discussion and activity parts, as a non-admin member', () => { + let stack: VerifyStack; + let ql: any; + let adminTok = ''; + let memberTok = ''; + let openId = ''; + let privateId = ''; + + const call = async (token: string, method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, body: await res.json().catch(() => undefined) }; + }; + const idOf = (answer: Answer) => String(answer.body?.id ?? answer.body?.record?.id ?? answer.body?.data?.id); + + beforeAll(async () => { + stack = await bootStack(commentsFixtureStack as never, { + security: commentsFixtureSecurity(), + extraPlugins: [new AuditPlugin()], + orgContext: true, + }); + adminTok = await stack.signIn(); + memberTok = await stack.signUp('rp-streams-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + + // A record both read, with a thread of three comments (one a reply) and + // reactions; an update so its activity holds more than its creation. + const open = await call(adminTok, 'POST', '/data/cmt_open', { name: 'streams record' }); + expect(open.status, JSON.stringify(open.body)).toBeLessThan(300); + openId = idOf(open); + const renamed = await call(adminTok, 'PATCH', `/data/cmt_open/${openId}`, { name: 'streams record, renamed' }); + expect(renamed.status, JSON.stringify(renamed.body)).toBeLessThan(300); + const thread = `cmt_open:${openId}`; + for (const [token, body] of [[adminTok, 'first'], [memberTok, 'second']] as const) { + const posted = await call(token, 'POST', '/data/sys_comment', { thread_id: thread, body }); + expect(posted.status, JSON.stringify(posted.body)).toBeLessThan(300); + } + const comments: any[] = await ql.find('sys_comment', { where: { thread_id: thread }, orderBy: [{ field: 'created_at', order: 'asc' }], context: SYS }); + expect(comments).toHaveLength(2); + const reply = await call(adminTok, 'POST', '/data/sys_comment', { thread_id: thread, body: 'a reply', parent_id: String(comments[0].id) }); + expect(reply.status, JSON.stringify(reply.body)).toBeLessThan(300); + for (const emoji of ['๐Ÿ‘', '๐ŸŽ‰']) { + const reacted = await call(adminTok, 'POST', '/data/sys_comment_reaction', { comment_id: String(comments[0].id), emoji }); + expect(reacted.status, JSON.stringify(reacted.body)).toBeLessThan(300); + } + + // A record the member cannot read, with a comment of its own. + const priv = await call(adminTok, 'POST', '/data/cmt_private', { name: 'not yours' }); + expect(priv.status, JSON.stringify(priv.body)).toBeLessThan(300); + privateId = idOf(priv); + const hidden = await call(adminTok, 'POST', '/data/sys_comment', { thread_id: `cmt_private:${privateId}`, body: 'hidden' }); + expect(hidden.status, JSON.stringify(hidden.body)).toBeLessThan(300); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('answers the discussion and the activity as the member\'s own reads of them answer', async () => { + const page = await call(memberTok, 'POST', `/data/cmt_open/${openId}/page`, { discussion: true, activity: { top: 200 } }); + expect(page.status, JSON.stringify(page.body)).toBe(200); + const data = RecordPageResponseSchema.parse(page.body.data); + if (!data.discussion || 'error' in data.discussion) throw new Error(`discussion refused: ${JSON.stringify(data.discussion)}`); + if (!data.activity || 'error' in data.activity) throw new Error(`activity refused: ${JSON.stringify(data.activity)}`); + + // The thread, as the record feed's comment read answers it. + const thread = await call(memberTok, 'GET', `/data/sys_comment?$filter=${q({ thread_id: `cmt_open:${openId}` })}&sort=created_at`); + expect(thread.status).toBe(200); + expect(data.discussion.comments).toEqual(rowsOf(thread.body)); + expect(data.discussion.comments.map((c: any) => c.body)).toEqual(['first', 'second', 'a reply']); + + // Their reactions, as the feed's reaction read answers it. + const ids = data.discussion.comments.map((c: any) => String(c.id)); + const reactions = await call(memberTok, 'GET', `/data/sys_comment_reaction?$filter=${q({ comment_id: { $in: ids } })}&sort=created_at`); + expect(reactions.status).toBe(200); + expect(data.discussion.reactions).toEqual({ records: rowsOf(reactions.body) }); + expect(rowsOf(reactions.body).map((r: any) => r.emoji)).toEqual(['๐Ÿ‘', '๐ŸŽ‰']); + + // The activity, newest first, the actor expanded, with its count. + const activity = await call( + memberTok, + 'GET', + `/data/sys_activity?$filter=${q({ object_name: 'cmt_open', record_id: openId })}&sort=-timestamp&$top=200&$count=true&$expand=actor_id`, + ); + expect(activity.status).toBe(200); + expect(data.activity).toEqual({ total: totalOf(activity.body), records: rowsOf(activity.body) }); + expect(data.activity.total).toBeGreaterThanOrEqual(2); + }, 120_000); + + it('a smaller activity window answers the newest rows of the same read, and the whole count', async () => { + const page = await call(memberTok, 'POST', `/data/cmt_open/${openId}/page`, { activity: { top: 1 } }); + const data = RecordPageResponseSchema.parse(page.body.data); + const activity = await call( + memberTok, + 'GET', + `/data/sys_activity?$filter=${q({ object_name: 'cmt_open', record_id: openId })}&sort=-timestamp&$top=1&$count=true&$expand=actor_id`, + ); + expect(data.activity).toEqual({ total: totalOf(activity.body), records: rowsOf(activity.body) }); + expect(rowsOf(activity.body)).toHaveLength(1); + expect(data).not.toHaveProperty('discussion'); + }, 60_000); + + it('a record the member cannot read refuses the whole request, its thread with it', async () => { + const read = await call(memberTok, 'GET', `/data/cmt_private/${privateId}`); + const page = await call(memberTok, 'POST', `/data/cmt_private/${privateId}/page`, { discussion: true, activity: { top: 5 } }); + expect(read.status).toBe(404); + expect(page).toEqual(read); + }, 60_000); +}); + +describe('[#28470] the record page attachments part, as a non-admin member', () => { + let harness: AttachmentsHarness; + let stack: VerifyStack; + let ql: any; + let adminTok = ''; + let memberTok = ''; + let caseId = ''; + + const attach = async (parentObject: string, parentId: string) => { + const fileId = await uploadFile(stack, adminTok); + const res = await stack.apiAs(adminTok, 'POST', '/data/sys_attachment', { + parent_object: parentObject, + parent_id: parentId, + file_id: fileId, + file_name: 'hello.txt', + mime_type: 'text/plain', + size: FILE_BYTES.length, + }); + expect(res.status, `attach to ${parentObject}`).toBeLessThan(300); + }; + + beforeAll(async () => { + harness = await bootAttachmentsHarness(); + stack = harness.stack; + adminTok = await stack.signIn(); + memberTok = await stack.signUp('rp-attachments-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + const adminId = String((await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id); + + const caseRes = await stack.apiAs(adminTok, 'POST', '/data/att_case', { name: 'page case' }); + expect(caseRes.status).toBeLessThan(300); + const caseBody = (await caseRes.json()) as any; + caseId = String(caseBody.id ?? caseBody.record?.id ?? caseBody.data?.id); + const secretId = String((await ql.insert('att_secret', { name: 'page secret', owner_id: adminId }, { context: SYS })).id); + for (let i = 0; i < 3; i++) await attach('att_case', caseId); + for (let i = 0; i < 2; i++) await attach('att_secret', secretId); + }, 180_000); + + afterAll(async () => { + await stopAttachmentsHarness(harness); + }); + + it('answers the newest attachments and their count as the member\'s own attachment read does', async () => { + const page = await stack.apiAs(memberTok, 'POST', `/data/att_case/${caseId}/page`, { attachments: { top: 2 } }); + const pageBody: any = await page.json(); + expect(page.status, JSON.stringify(pageBody)).toBe(200); + const data = RecordPageResponseSchema.parse(pageBody.data); + const list = await stack.apiAs( + memberTok, + 'GET', + `/data/sys_attachment?$filter=${q({ parent_object: 'att_case', parent_id: caseId })}&sort=-created_at&$top=2&$count=true`, + ); + expect(list.status).toBe(200); + const listBody: any = await list.json(); + expect(data.attachments).toEqual({ total: totalOf(listBody), records: rowsOf(listBody) }); + // The case's three, never the secret's two; two rows on the page. + expect(totalOf(listBody)).toBe(3); + expect(rowsOf(listBody)).toHaveLength(2); + }, 60_000); +}); diff --git a/packages/qa/dogfood/test/record-page.dogfood.test.ts b/packages/qa/dogfood/test/record-page.dogfood.test.ts index 231c3808d16..a90ce891ef2 100644 --- a/packages/qa/dogfood/test/record-page.dogfood.test.ts +++ b/packages/qa/dogfood/test/record-page.dogfood.test.ts @@ -147,6 +147,20 @@ describe('[#27627] the record page payload, as a non-admin member', () => { expect(data.approvals).toMatchObject({ requests: requests.body?.data ?? [] }); }, 120_000); + it('[#28470] a related list\'s expand is its read\'s own $expand: the rows carry the resolved references', async () => { + const page = await send(`/data/rp_account/${accountId}/page`, 'POST', { + related: [{ object: 'rp_note', field: 'account', top: 3, expand: ['account'] }], + }); + expect(page.status, JSON.stringify(page.body)).toBe(200); + const data = RecordPageResponseSchema.parse(page.body.data); + const notes = await send( + `/data/rp_note?$filter=${encodeURIComponent(JSON.stringify({ account: accountId }))}&$top=3&$count=true&$expand=account`, + ); + expect(notes.status).toBe(200); + expect(data.related[0]).toEqual({ object: 'rp_note', field: 'account', total: totalOf(notes.body), records: rowsOf(notes.body) }); + for (const row of rowsOf(notes.body)) expect(row.account).toMatchObject({ id: accountId, name: 'Acme' }); + }, 60_000); + it('a record the member cannot read refuses the whole request exactly as the record read does', async () => { for (const id of [privateId, 'rp_no_such_row']) { const read = await send(`/data/rp_private/${id}`); diff --git a/packages/rest/src/record-page-routes.test.ts b/packages/rest/src/record-page-routes.test.ts index d4ee530ce44..ffa771b18f7 100644 --- a/packages/rest/src/record-page-routes.test.ts +++ b/packages/rest/src/record-page-routes.test.ts @@ -18,7 +18,11 @@ * related list the caller may not read carries neither a count nor rows; * - an entry that is not a relationship of the child pointing at this object * is a located 400 before anything is read; - * - the body is strict, and the query-parameter set is closed and empty. + * - the body is strict, and the query-parameter set is closed and empty; + * - [#28470] a related list's `expand` is its read's own `$expand`, and each + * stream part โ€” the discussion (the thread, then its reactions), the + * activity and the attachments โ€” is the `findData` the console's own read of + * it makes, behind that object's exposure gate, refused on its own. * * The end-to-end half, under row-level and field-level security as a member who * is not an admin, is `record-page.dogfood.test.ts`. @@ -52,7 +56,16 @@ const HIDDEN = { enable: { apiEnabled: false }, fields: { account: { name: 'account', type: 'lookup', reference: 'account' } }, }; -const ITEMS: Record = { account: ACCOUNT, note: NOTE, hidden: HIDDEN }; +const systemObject = (name: string) => ({ name, label: name, fields: { id: { name: 'id', type: 'text' } } }); +const ITEMS: Record = { + account: ACCOUNT, + note: NOTE, + hidden: HIDDEN, + sys_comment: systemObject('sys_comment'), + sys_comment_reaction: systemObject('sys_comment_reaction'), + sys_activity: systemObject('sys_activity'), + sys_attachment: systemObject('sys_attachment'), +}; const CTX = { userId: 'u1', permissions: ['member_default'] }; const RECORD = { id: 'a1', name: 'Acme' }; @@ -87,6 +100,7 @@ function mockRes() { } interface BootOpts { + items?: Record; getData?: (req: any) => Promise; findData?: (req: any) => Promise; security?: unknown; @@ -106,8 +120,11 @@ function boot(opts: BootOpts = {}) { version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, }), getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItems: vi.fn().mockResolvedValue({ items: Object.values(ITEMS) }), - getMetaItem: vi.fn(async ({ name }: any) => (ITEMS[name] ? { type: 'object', name, item: ITEMS[name] } : null)), + getMetaItems: vi.fn().mockResolvedValue({ items: Object.values(opts.items ?? ITEMS) }), + getMetaItem: vi.fn(async ({ name }: any) => { + const items = opts.items ?? ITEMS; + return items[name] ? { type: 'object', name, item: items[name] } : null; + }), getData, findData, }; @@ -335,3 +352,172 @@ describe('[#27627] POST /data/:object/:id/page', () => { expect(getData).not.toHaveBeenCalled(); }); }); + +const objectNotFound = (name: string) => Object.assign(new Error(`Object '${name}' not found`), { + code: 'OBJECT_NOT_FOUND', status: 404, +}); + +describe('[#28470] the record\'s streams and the related rows\' names ride in the payload', () => { + // Answers each stream read with rows of its own object, so a part that + // carried another read's rows would show. + const rowsOf = (req: any) => { + if (req.object === 'sys_comment') return { records: [{ id: 'c1' }, { id: 'c2', parent_id: 'c1' }], total: 2 }; + if (req.object === 'sys_comment_reaction') return { records: [{ id: 'x1', comment_id: 'c1', emoji: '๐Ÿ‘', user_id: 'u1' }], total: 1 }; + if (req.object === 'sys_activity') return { records: [{ id: 'act1', type: 'updated' }], total: 9 }; + if (req.object === 'sys_attachment') return { records: [{ id: 'f1', name: 'a.pdf' }], total: 4 }; + return { records: [{ id: `${req.object}-1`, owner: { id: 'user-1', name: 'Una' } }], total: 7 }; + }; + + it('answers each stream with the read the console makes for it, as the caller, in the schema\'s shape', async () => { + const { call, findData } = boot({ findData: async (req) => rowsOf(req) }); + const answer = await call({ + related: [{ object: 'note', field: 'account', top: 3, expand: ['owner'] }], + discussion: true, + activity: { top: 200 }, + attachments: { top: 100 }, + }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + const reads = findData.mock.calls.map(([req]) => req); + + // The related list's read carries its own `$expand`, and its rows the + // resolved references. + expect(reads).toContainEqual({ + object: 'note', + query: { object: 'note', where: { account: 'a1' }, limit: 3, count: true, expand: ['owner'] }, + context: CTX, + }); + expect(data.related[0]).toEqual({ object: 'note', field: 'account', total: 7, records: [{ id: 'note-1', owner: { id: 'user-1', name: 'Una' } }] }); + + // The discussion: the whole thread (no page size), oldest first; then + // the reactions of exactly those comments, oldest first. + expect(reads).toContainEqual({ + object: 'sys_comment', + query: { object: 'sys_comment', where: { thread_id: 'account:a1' }, orderBy: [{ field: 'created_at', order: 'asc' }] }, + context: CTX, + }); + expect(reads).toContainEqual({ + object: 'sys_comment_reaction', + query: { object: 'sys_comment_reaction', where: { comment_id: { $in: ['c1', 'c2'] } }, orderBy: [{ field: 'created_at', order: 'asc' }] }, + context: CTX, + }); + expect(data.discussion).toEqual({ + comments: [{ id: 'c1' }, { id: 'c2', parent_id: 'c1' }], + reactions: { records: [{ id: 'x1', comment_id: 'c1', emoji: '๐Ÿ‘', user_id: 'u1' }] }, + }); + + // The activity: the newest `top` rows with their count, the actor expanded. + expect(reads).toContainEqual({ + object: 'sys_activity', + query: { + object: 'sys_activity', + where: { object_name: 'account', record_id: 'a1' }, + orderBy: [{ field: 'timestamp', order: 'desc' }], + limit: 200, + count: true, + expand: ['actor_id'], + }, + context: CTX, + }); + expect(data.activity).toEqual({ total: 9, records: [{ id: 'act1', type: 'updated' }] }); + + // The attachments: the newest `top` rows with their count. + expect(reads).toContainEqual({ + object: 'sys_attachment', + query: { + object: 'sys_attachment', + where: { parent_object: 'account', parent_id: 'a1' }, + orderBy: [{ field: 'created_at', order: 'desc' }], + limit: 100, + count: true, + }, + context: CTX, + }); + expect(data.attachments).toEqual({ total: 4, records: [{ id: 'f1', name: 'a.pdf' }] }); + expect(reads).toHaveLength(5); + }); + + it('a thread with no comment asks no reaction read, and answers no reactions', async () => { + const { call, findData } = boot({ findData: async (req) => (req.object === 'sys_comment' ? { records: [], total: 0 } : rowsOf(req)) }); + const answer = await call({ discussion: true }); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.discussion).toEqual({ comments: [], reactions: { records: [] } }); + expect(findData.mock.calls.map(([req]) => req.object)).toEqual(['sys_comment']); + }); + + it('a stream whose read refuses carries the refusal alone; the record and the other parts answer', async () => { + const { call } = boot({ + findData: async (req) => { + if (req.object === 'sys_activity') throw permissionDenied(); + return rowsOf(req); + }, + }); + const answer = await call({ related: [{ object: 'note', field: 'account', top: 1 }], discussion: true, activity: { top: 5 }, attachments: { top: 5 } }); + expect(answer.status).toBe(200); + const data = RecordPageResponseSchema.parse(answer.body.data); + expect(data.record).toEqual(RECORD); + expect(data.activity).toEqual({ error: { code: 'PERMISSION_DENIED', status: 403, message: expect.any(String) } }); + expect(data.discussion).toMatchObject({ comments: [{ id: 'c1' }, { id: 'c2', parent_id: 'c1' }] }); + expect(data.attachments).toEqual({ total: 4, records: [{ id: 'f1', name: 'a.pdf' }] }); + expect(data.related[0]).toMatchObject({ total: 7 }); + }); + + it('a refused comment read refuses the discussion and asks no reaction read', async () => { + const { call, findData } = boot({ findData: async () => { throw permissionDenied(); } }); + const data = RecordPageResponseSchema.parse((await call({ discussion: true })).body.data); + expect(data.discussion).toEqual({ error: { code: 'PERMISSION_DENIED', status: 403, message: expect.any(String) } }); + expect(findData.mock.calls.map(([req]) => req.object)).toEqual(['sys_comment']); + }); + + it('a refused reaction read leaves the comments answered (a deployment without sys_comment_reaction)', async () => { + const items = { ...ITEMS }; + delete items.sys_comment_reaction; + const { call } = boot({ + items, + findData: async (req) => { + if (req.object === 'sys_comment_reaction') throw objectNotFound('sys_comment_reaction'); + return rowsOf(req); + }, + }); + const data = RecordPageResponseSchema.parse((await call({ discussion: true })).body.data); + expect(data.discussion).toEqual({ + comments: [{ id: 'c1' }, { id: 'c2', parent_id: 'c1' }], + reactions: { error: { code: expect.any(String), status: 404, message: expect.any(String) } }, + }); + }); + + it('a stream on an object the API does not expose is refused as its list read is, and never read', async () => { + const items = { + ...ITEMS, + sys_activity: { ...ITEMS.sys_activity, enable: { apiEnabled: false } }, + sys_attachment: { ...ITEMS.sys_attachment, enable: { apiEnabled: false } }, + sys_comment: { ...ITEMS.sys_comment, enable: { apiEnabled: false } }, + }; + const { call, findData } = boot({ items, findData: async (req) => rowsOf(req) }); + const data = RecordPageResponseSchema.parse((await call({ discussion: true, activity: { top: 5 }, attachments: { top: 5 } })).body.data); + for (const part of [data.discussion, data.activity, data.attachments]) { + expect(part).toEqual({ error: expect.objectContaining({ code: 'OBJECT_API_DISABLED', status: 404 }) }); + } + expect(findData).not.toHaveBeenCalled(); + }); + + it('the widened body stays strict: a window past its cap, an undeclared key or a non-boolean flag is a 400 and nothing is read', async () => { + const { call, getData, findData } = boot(); + for (const body of [ + { activity: { top: 201 } }, + { activity: { top: 0 } }, + { activity: { top: 5, sort: 'asc' } }, + { attachments: { top: 101 } }, + { attachments: { top: 5, filter: {} } }, + { discussion: 'yes' }, + { related: [{ object: 'note', field: 'account', top: 3, expand: 'owner' }] }, + { mentions: { top: 50 } }, + ]) { + const answer = await call(body); + expect(answer.status, JSON.stringify(body)).toBe(400); + expect(answer.body.error.code).toBe('VALIDATION_FAILED'); + } + expect(getData).not.toHaveBeenCalled(); + expect(findData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts index 36f0790c186..10965fbb918 100644 --- a/packages/rest/src/record-page-routes.ts +++ b/packages/rest/src/record-page-routes.ts @@ -13,7 +13,12 @@ * `findData` with `count` after the child's own exposure gate, filtered by the * parent condition this route composes AND the list's declared scope; then * `getRecordFieldAccess`, the security service's `explain` and the approval - * service's `listRequests` / `getRequest` / `getRecordLock`. Object + * service's `listRequests` / `getRequest` / `getRecordLock`. [#28470] A + * related list's `expand` is that list read's own `$expand`; the record's + * streams โ€” `discussion`, `activity`, `attachments` โ€” are `findData` over + * `sys_comment` (then `sys_comment_reaction`), `sys_activity` and + * `sys_attachment`, each after that object's own exposure gate, scoped to this + * record and ordered as the spec module's stream table spells it. Object * permissions, row-level security, sharing and field-level security are * therefore whatever those calls apply, by construction. * @@ -46,6 +51,12 @@ export const RECORD_PAGE_PARAMS: readonly string[] = []; /** The relationship field types a related list follows back to its parent. */ const RELATIONSHIP_TYPES = new Set(['lookup', 'master_detail']); +/** [#28470] The objects the record's stream parts read. */ +const COMMENT_OBJECT = 'sys_comment'; +const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; +const ACTIVITY_OBJECT = 'sys_activity'; +const ATTACHMENT_OBJECT = 'sys_attachment'; + /** * The `RestServer` members {@link registerRecordPageEndpoints} reads โ€” every * one of them, and no other (`data-action-routes.ts`' header carries why each @@ -141,9 +152,18 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: // caller may not reach through the API is not judged on its // field: its read below answers that refusal. const related = body.related ?? []; - const childItems = await measureServerTiming('meta', () => Promise.all( - related.map((entry) => this.loadObjectItem(p, environmentId, entry.object)), - ), 'Metadata reads'); + // [#28470] The stream objects' items too, for their exposure + // gates โ€” read in the same metadata wave as the children's. + const streamObjects = [ + ...(body.discussion ? [COMMENT_OBJECT, COMMENT_REACTION_OBJECT] : []), + ...(body.activity ? [ACTIVITY_OBJECT] : []), + ...(body.attachments ? [ATTACHMENT_OBJECT] : []), + ]; + const [childItems, streamItemList] = await measureServerTiming('meta', () => Promise.all([ + Promise.all(related.map((entry) => this.loadObjectItem(p, environmentId, entry.object))), + Promise.all(streamObjects.map((name) => this.loadObjectItem(p, environmentId, name))), + ]), 'Metadata reads'); + const streamItems = new Map(streamObjects.map((name, index) => [name, streamItemList[index]])); const malformed: Array<{ field: string; code: string; message: string }> = []; const declaredFilters = related.map((entry, index) => { if (entry.filter === undefined) return undefined; @@ -207,6 +227,9 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: limit: entry.top, count: true, ...(entry.sort && entry.sort.length > 0 ? { orderBy: entry.sort } : {}), + // [#28470] the list read's own `$expand`, so + // its rows carry the names the list draws. + ...(entry.expand && entry.expand.length > 0 ? { expand: entry.expand } : {}), }, ...scope, } satisfies ServerScopedDataRequest); @@ -218,6 +241,78 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: } }; + // [#28470] One stream read: `findData` over `name` as the + // caller, behind that object's own exposure gate. A refusal is + // the read's own, carried as the part's answer. + const readStream = async ( + name: string, + query: Record, + ): Promise<{ records: any[]; total: number } | { error: PartRefusal }> => { + const item = streamItems.get(name); + const denial = item ? apiAccessDenialFromEnable(item.enable, name, 'list') : null; + if (denial) { + const nested = nestedRefusalEnvelope(denial); + return { error: { code: nested.code, status: nested.status, message: nested.message } }; + } + try { + const found: any = await p.findData({ + object: name, + query: { object: name, ...query }, + ...scope, + } satisfies ServerScopedDataRequest); + const records = Array.isArray(found?.records) ? found.records : []; + const total = typeof found?.total === 'number' ? found.total : records.length; + return { records, total }; + } catch (error) { + return { error: refusalOf(error, name) }; + } + }; + + // The thread, as the record feed reads it (no page size), then + // its comments' reactions; the reactions' refusal leaves the + // comments answered. + const answerDiscussion = async () => { + const comments = await readStream(COMMENT_OBJECT, { + where: { thread_id: `${object}:${id}` }, + orderBy: [{ field: 'created_at', order: 'asc' }], + }); + if ('error' in comments) return { error: comments.error }; + const ids = comments.records + .map((row: any) => row?.id) + .filter((value: unknown) => typeof value === 'string' || typeof value === 'number') + .map(String); + if (ids.length === 0) return { comments: comments.records, reactions: { records: [] } }; + const reactions = await readStream(COMMENT_REACTION_OBJECT, { + where: { comment_id: { $in: ids } }, + orderBy: [{ field: 'created_at', order: 'asc' }], + }); + return { + comments: comments.records, + reactions: 'error' in reactions ? { error: reactions.error } : { records: reactions.records }, + }; + }; + + const answerActivity = async (top: number) => { + const answer = await readStream(ACTIVITY_OBJECT, { + where: { object_name: object, record_id: id }, + orderBy: [{ field: 'timestamp', order: 'desc' }], + limit: top, + count: true, + expand: ['actor_id'], + }); + return 'error' in answer ? answer : { total: answer.total, records: answer.records }; + }; + + const answerAttachments = async (top: number) => { + const answer = await readStream(ATTACHMENT_OBJECT, { + where: { parent_object: object, parent_id: id }, + orderBy: [{ field: 'created_at', order: 'desc' }], + limit: top, + count: true, + }); + return 'error' in answer ? answer : { total: answer.total, records: answer.records }; + }; + const answerFieldAccess = async () => { try { const security = await this.resolveSecurityService(environmentId, req); @@ -282,11 +377,14 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: } }; - const [relatedAnswers, fieldAccess, explain, approvals] = await Promise.all([ + const [relatedAnswers, fieldAccess, explain, approvals, discussion, activity, attachments] = await Promise.all([ Promise.all(related.map(answerRelated)), body.fieldAccess ? answerFieldAccess() : Promise.resolve(undefined), body.explain && body.explain.length > 0 ? answerExplain() : Promise.resolve(undefined), body.approvals ? answerApprovals() : Promise.resolve(undefined), + body.discussion ? answerDiscussion() : Promise.resolve(undefined), + body.activity ? answerActivity(body.activity.top) : Promise.resolve(undefined), + body.attachments ? answerAttachments(body.attachments.top) : Promise.resolve(undefined), ]); sendOk(res, { @@ -297,6 +395,9 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: ...(fieldAccess !== undefined ? { fieldAccess } : {}), ...(explain !== undefined ? { explain } : {}), ...(approvals !== undefined ? { approvals } : {}), + ...(discussion !== undefined ? { discussion } : {}), + ...(activity !== undefined ? { activity } : {}), + ...(attachments !== undefined ? { attachments } : {}), }); } catch (error: any) { const mapped = mapDataError(error, object); @@ -305,7 +406,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: } }, metadata: { - summary: 'Record page payload: the record, its related lists\' counts and first rows, its field projection, access verdicts and approval state in one request', + summary: 'Record page payload: the record, its related lists\' counts and first rows, its field projection, access verdicts, approval state, discussion, activity and attachments in one request', tags: ['data'], }, }); diff --git a/packages/spec/src/api/record-page.test.ts b/packages/spec/src/api/record-page.test.ts index 245233ca92d..66c5488760c 100644 --- a/packages/spec/src/api/record-page.test.ts +++ b/packages/spec/src/api/record-page.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest'; import { + RECORD_PAGE_MAX_ACTIVITY_ROWS, + RECORD_PAGE_MAX_ATTACHMENT_ROWS, + RecordPageDiscussionSchema, RecordPageExplainOperationSchema, RecordPageRelatedAnswerSchema, + RecordPageRequestSchema, RecordPageResponseSchema, } from './record-page.zod'; @@ -40,3 +44,71 @@ describe('[#27627] the explain part carries exactly the operations the request a } }); }); + +describe('[#28470] the record\'s streams and the related rows\' names are named by the request, strictly', () => { + const ok = (body: unknown) => RecordPageRequestSchema.safeParse(body).success; + + it('takes a related list\'s expand, the discussion flag and each stream\'s window', () => { + expect(ok({ + related: [{ object: 'note', field: 'account', top: 3, expand: ['owner', 'contact'] }], + discussion: true, + activity: { top: RECORD_PAGE_MAX_ACTIVITY_ROWS }, + attachments: { top: RECORD_PAGE_MAX_ATTACHMENT_ROWS }, + })).toBe(true); + expect(ok({ attachments: { top: 0 } })).toBe(true); + }); + + it('refuses a window past its cap, an empty activity window, a key a part does not declare and a non-boolean flag', () => { + for (const body of [ + { activity: { top: RECORD_PAGE_MAX_ACTIVITY_ROWS + 1 } }, + { activity: { top: 0 } }, + { activity: {} }, + { activity: { top: 5, order: 'asc' } }, + { attachments: { top: RECORD_PAGE_MAX_ATTACHMENT_ROWS + 1 } }, + { attachments: { top: 5, filter: ['name', '=', 'x'] } }, + { discussion: 'yes' }, + { discussion: { top: 5 } }, + { related: [{ object: 'note', field: 'account', top: 3, expand: [''] }] }, + { related: [{ object: 'note', field: 'account', top: 3, expand: 'owner' }] }, + { mentions: true }, + ]) { + expect(ok(body), JSON.stringify(body)).toBe(false); + } + }); +}); + +describe('[#28470] a stream part answers its rows or its refusal, never both and never neither', () => { + const base = { object: 'account', id: 'a1', record: { id: 'a1' }, related: [] }; + const parses = (part: Record) => RecordPageResponseSchema.safeParse({ ...base, ...part }).success; + + it('takes rows with a count, and a refusal alone, for activity and attachments', () => { + for (const key of ['activity', 'attachments']) { + expect(parses({ [key]: { total: 1, records: [{ id: 'r1' }] } }), key).toBe(true); + expect(parses({ [key]: { error: refusal } }), key).toBe(true); + for (const answer of [ + { total: 0, records: [], error: refusal }, + { records: [], error: refusal }, + { total: 2 }, + { records: [] }, + {}, + ]) { + expect(parses({ [key]: answer }), `${key} ${JSON.stringify(answer)}`).toBe(false); + } + } + }); + + it('takes the thread with its reactions or their refusal, and the comment read\'s refusal alone', () => { + expect(parses({ discussion: { comments: [{ id: 'c1' }], reactions: { records: [{ id: 'x1' }] } } })).toBe(true); + expect(parses({ discussion: { comments: [{ id: 'c1' }], reactions: { error: { ...refusal, status: 404, code: 'OBJECT_NOT_FOUND' } } } })).toBe(true); + expect(parses({ discussion: { error: refusal } })).toBe(true); + for (const answer of [ + { comments: [], reactions: { records: [] }, error: refusal }, + { comments: [] }, + { reactions: { records: [] } }, + { comments: [], reactions: { records: [], error: refusal } }, + { comments: [], reactions: {} }, + ]) { + expect(RecordPageDiscussionSchema.safeParse(answer).success && parses({ discussion: answer }), JSON.stringify(answer)).toBe(false); + } + }); +}); diff --git a/packages/spec/src/api/record-page.zod.ts b/packages/spec/src/api/record-page.zod.ts index 160e306ec6b..35f46b9f4d5 100644 --- a/packages/spec/src/api/record-page.zod.ts +++ b/packages/spec/src/api/record-page.zod.ts @@ -10,9 +10,11 @@ import { GetRecordFieldAccessResponseSchema } from './protocol.zod'; * * One request that answers everything a record page asks of the server when it * opens: the record (with the lookups it names resolved), the count and first - * rows of every related list the page draws, the caller's field projection of - * the record, the approval state that locks it, and the record-grained access - * verdicts its header actions are gated on. + * rows of every related list the page draws (with the lookups of those rows it + * names resolved), the caller's field projection of the record, the approval + * state that locks it, the record-grained access verdicts its header actions + * are gated on, and the record's own streams the page draws โ€” its discussion, + * its activity feed and its attachments. * * The request NAMES the parts. Which related lists a page draws (their order, * their declared scope, which of them the reader may see) is the page's own @@ -24,15 +26,24 @@ import { GetRecordFieldAccessResponseSchema } from './protocol.zod'; * * Every part is the answer of the read it replaces, asked as the same caller: * the record is `GET /data/{object}/{id}`, a related list is - * `GET /data/{child}` with that filter and `$count`, `fieldAccess` is - * `GET /data/{object}/{id}/field-access`, `explain` is - * `POST /security/explain` with the record id, and `approvals` is + * `GET /data/{child}` with that filter, `$count` and the `$expand` it names, + * `fieldAccess` is `GET /data/{object}/{id}/field-access`, `explain` is + * `POST /security/explain` with the record id, `approvals` is * `GET /approvals/requests` for the record plus - * `GET /approvals/records/{object}/{id}/lock`. Object permissions, row-level - * security, sharing and field-level security therefore apply exactly as they - * apply to those reads. A part whose read refuses carries that refusal in place - * of an answer โ€” a related list the caller may not read carries neither a - * count nor rows โ€” and the other parts are answered. A record the caller + * `GET /approvals/records/{object}/{id}/lock`, `discussion` is + * `GET /data/sys_comment` for the record's thread plus + * `GET /data/sys_comment_reaction` for those comments, `activity` is + * `GET /data/sys_activity` for the record with its actor expanded, and + * `attachments` is `GET /data/sys_attachment` for the record with `$count`. + * The three stream parts are each that read exactly as the table on + * `RecordPageActivityRequestSchema` spells it: the route composes the record's + * scope and the order, and the request names only whether a stream is wanted + * and how many rows. Paging further, posting, reacting and uploading keep + * their own routes. Object permissions, row-level security, sharing and + * field-level security therefore apply exactly as they apply to those reads. + * A part whose read refuses carries that refusal in place of an answer โ€” a + * related list the caller may not read carries neither a count nor rows โ€” and + * the other parts are answered. A record the caller * cannot read refuses the whole request with the record read's own answer, so * no part discloses a record the caller could not open. */ @@ -43,6 +54,14 @@ export const RECORD_PAGE_MAX_RELATED = 50; export const RECORD_PAGE_MAX_RELATED_ROWS = 50; /** The most sort keys one related list may order its rows by. */ export const RECORD_PAGE_MAX_RELATED_SORT = 8; +/** + * The most activity rows the `activity` part may ask for: the window the + * console's record feed reads (`$top: 200`), which its count and its ๅŠจๆ€ tab + * are drawn from. + */ +export const RECORD_PAGE_MAX_ACTIVITY_ROWS = 200; +/** The most attachment rows the `attachments` part may ask for: the Attachments panel's window. */ +export const RECORD_PAGE_MAX_ATTACHMENT_ROWS = 100; /** The record-grained operations a record page's header asks about. */ export const RecordPageExplainOperationSchema = z.enum(['update', 'delete', 'transfer']); @@ -70,10 +89,51 @@ export const RecordPageRelatedRequestSchema = lazySchema(() => z.object({ }).strict()).max(RECORD_PAGE_MAX_RELATED_SORT).optional().describe( 'The order of the list\'s first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order.', ), + expand: z.array(z.string().min(1)).optional().describe( + 'Lookup fields of the child object to resolve on the list\'s rows, as `GET /data/{object}?$expand=` resolves ' + + 'them: each named reference is answered with the referenced record the caller may read in place of its id, so ' + + 'the rows carry the display names the list draws. Absent: the rows carry the bare ids.', + ), }).strict()); export type RecordPageRelatedRequest = z.input; export type RecordPageRelatedRequestParsed = z.infer; +/** + * The record's own streams, each the read it replaces (the record is + * `{object}` / `{id}` of the route): + * + * | part | the read | + * | ------------- | ---------------------------------------------------------------------------------------------- | + * | `discussion` | `sys_comment` where `thread_id` = `{object}:{id}` (the id the comment writer stores), by | + * | | `created_at` asc, no page size (the whole thread, as the record feed reads it); then | + * | | `sys_comment_reaction` where `comment_id` is among those comments, by `created_at` asc | + * | `activity` | `sys_activity` where `object_name` = `{object}` and `record_id` = `{id}`, by `timestamp` desc, | + * | | `top` rows with `$count`, `actor_id` expanded | + * | `attachments` | `sys_attachment` where `parent_object` = `{object}` and `parent_id` = `{id}`, by `created_at` | + * | | desc, `top` rows with `$count` | + * + * Each is asked of the data door as the caller, behind that object's own API + * exposure gate, so a stream the caller may not read is that read's refusal. + * + * This schema is the `activity` part of a request: how many of the newest rows. + */ +export const RecordPageActivityRequestSchema = lazySchema(() => z.object({ + top: z.number().int().min(1).max(RECORD_PAGE_MAX_ACTIVITY_ROWS).describe( + 'How many of the record\'s newest activity rows to return with their count.', + ), +}).strict()); +export type RecordPageActivityRequest = z.input; +export type RecordPageActivityRequestParsed = z.infer; + +/** The `attachments` part of a request: how many of the newest attachments. */ +export const RecordPageAttachmentsRequestSchema = lazySchema(() => z.object({ + top: z.number().int().min(0).max(RECORD_PAGE_MAX_ATTACHMENT_ROWS).describe( + 'How many of the record\'s newest attachments to return with their count; 0 returns the count alone.', + ), +}).strict()); +export type RecordPageAttachmentsRequest = z.input; +export type RecordPageAttachmentsRequestParsed = z.infer; + /** The request body of `POST /data/{object}/{id}/page`. */ export const RecordPageRequestSchema = lazySchema(() => z.object({ expand: z.array(z.string().min(1)).optional().describe( @@ -92,6 +152,15 @@ export const RecordPageRequestSchema = lazySchema(() => z.object({ approvals: z.boolean().optional().describe( 'Return the record\'s approval requests and its approval lock.', ), + discussion: z.boolean().optional().describe( + 'Return the record\'s discussion: its comment thread and those comments\' reactions (`RecordPageDiscussionSchema`).', + ), + activity: RecordPageActivityRequestSchema.optional().describe( + 'Return the record\'s newest activity rows and their count (`RecordPageStreamRowsSchema`).', + ), + attachments: RecordPageAttachmentsRequestSchema.optional().describe( + 'Return the record\'s newest attachments and their count (`RecordPageStreamRowsSchema`).', + ), }).strict()); export type RecordPageRequest = z.input; export type RecordPageRequestParsed = z.infer; @@ -153,6 +222,48 @@ export const RecordPageApprovalsSchema = lazySchema(() => z.object({ export type RecordPageApprovals = z.input; export type RecordPageApprovalsParsed = z.infer; +/** + * A stream part whose read refused: the refusal alone, so a refused stream + * cannot be read as an empty one and an answered one carries no refusal. + */ +export const RecordPageStreamRefusalSchema = lazySchema(() => z.object({ + error: RecordPagePartRefusalSchema.describe('The refusal the replaced read answers, in place of this part\'s answer.'), +}).strict()); +export type RecordPageStreamRefusal = z.input; +export type RecordPageStreamRefusalParsed = z.infer; + +/** A stream's newest rows and how many it holds: the `activity` and `attachments` parts. */ +export const RecordPageStreamRowsSchema = lazySchema(() => z.object({ + total: z.number().int().describe('How many rows the stream holds for this caller.'), + records: z.array(z.record(z.string(), z.unknown())).describe('The stream\'s newest rows, as the caller may read them.'), +}).strict()); +export type RecordPageStreamRows = z.input; +export type RecordPageStreamRowsParsed = z.infer; + +/** The reactions of the discussion's comments: their rows, or the refusal of their read. */ +export const RecordPageDiscussionReactionsSchema = lazySchema(() => z.union([ + z.object({ + records: z.array(z.record(z.string(), z.unknown())).describe( + 'The `sys_comment_reaction` rows of the thread\'s comments, oldest first; empty for a thread with no comment.', + ), + }).strict(), + RecordPageStreamRefusalSchema, +])); +export type RecordPageDiscussionReactions = z.input; +export type RecordPageDiscussionReactionsParsed = z.infer; + +/** + * The discussion part: the record's comment thread and its reactions. The + * reactions are a part of their own: their refusal (a deployment without + * `sys_comment_reaction` answers its 404) leaves the comments answered. + */ +export const RecordPageDiscussionSchema = lazySchema(() => z.object({ + comments: z.array(z.record(z.string(), z.unknown())).describe('The thread\'s comments, oldest first, as the caller may read them.'), + reactions: RecordPageDiscussionReactionsSchema.describe('The reactions of those comments, or the refusal of their read.'), +}).strict()); +export type RecordPageDiscussion = z.input; +export type RecordPageDiscussionParsed = z.infer; + /** One requested operation's access decision, or the refusal of the explain read. */ export const RecordPageExplainAnswerSchema = lazySchema(() => z.union([ExplainDecisionSchema, RecordPagePartErrorSchema])); export type RecordPageExplainAnswer = z.input; @@ -179,6 +290,15 @@ export const RecordPageResponseSchema = lazySchema(() => z.object({ approvals: z.union([RecordPageApprovalsSchema, RecordPagePartErrorSchema]).optional().describe( 'The approval part, when the request asked for it.', ), + discussion: z.union([RecordPageDiscussionSchema, RecordPageStreamRefusalSchema]).optional().describe( + 'The discussion part, when the request asked for it: the thread and its reactions, or the refusal of the comment read.', + ), + activity: z.union([RecordPageStreamRowsSchema, RecordPageStreamRefusalSchema]).optional().describe( + 'The activity part, when the request asked for it: the newest rows and their count, or the refusal of their read.', + ), + attachments: z.union([RecordPageStreamRowsSchema, RecordPageStreamRefusalSchema]).optional().describe( + 'The attachments part, when the request asked for it: the newest rows and their count, or the refusal of their read.', + ), })); export type RecordPageResponse = z.input; export type RecordPageResponseParsed = z.infer; From db24fa2d2f17d3aa1e219b52cfe5fdd6050f5e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:01:10 +0000 Subject: [PATCH 2/7] feat(console): a record open is one request The record page asks its payload for the discussion and the activity when a block that draws the feed is on screen at open, and for the attachments when the page draws them. Each related list asks the lookups of its rows to resolve, as its own read expands them, and the related list's name fallback no longer resolves the parent record's own id. The mention roster is read on the composer's first focus, or by a reply or edit whose text names someone before then. Stream asks ride the payload the plan asks and never key it, so an assigned page landing after a synthesized one asks nothing again. Regenerated spec artifacts for the record page schema change; two changesets. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- .../28470-console-record-open-one-request.md | 21 ++ .changeset/28470-record-page-streams.md | 18 ++ content/docs/references/api/record-page.mdx | 214 ++++++++++++- content/docs/references/index.mdx | 10 +- .../api.md | 2 +- packages/spec/api-surface/api.json | 20 ++ packages/spec/authorable-surface/api.json | 14 + packages/spec/declaration-map/api.json | 12 + packages/spec/export-origins/api.json | 20 ++ packages/spec/json-schema.manifest/api.json | 6 + .../src/views/RecordAttachmentsPanel.tsx | 26 +- ...ilView.recordOpenOneRequest-28470.test.tsx | 284 ++++++++++++++++++ ...dDetailView.recordOpenWaves-13010.test.tsx | 90 ++++-- ...etailView.recordPagePayload-27627.test.tsx | 39 +-- .../app-shell/src/views/RecordDetailView.tsx | 207 ++++++++++--- ...DetailView.withheldMentions-28218.test.tsx | 23 +- .../app-shell/src/views/recordPagePayload.ts | 140 ++++++++- .../src/RecordActivityTimeline.tsx | 4 + .../plugin-detail/src/RecordChatterPanel.tsx | 5 + ui/packages/plugin-detail/src/RelatedList.tsx | 10 +- .../src/RichTextCommentInput.tsx | 5 + .../src/renderers/record-activity.tsx | 1 + .../src/renderers/record-chatter.tsx | 1 + .../react/src/context/DiscussionContext.tsx | 8 + ui/packages/types/src/data.ts | 15 + 25 files changed, 1073 insertions(+), 122 deletions(-) create mode 100644 .changeset/28470-console-record-open-one-request.md create mode 100644 .changeset/28470-record-page-streams.md create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx diff --git a/.changeset/28470-console-record-open-one-request.md b/.changeset/28470-console-record-open-one-request.md new file mode 100644 index 00000000000..a9f4334ab2e --- /dev/null +++ b/.changeset/28470-console-record-open-one-request.md @@ -0,0 +1,21 @@ +--- +'@object-ui/types': patch +'@object-ui/react': patch +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +feat(console): a record open is one request โ€” the discussion, the activity, the attachments and the related rows' names come with the page payload (#28470) + +Clause-โ‘ก: yes (widening) +Metadata-protocol: none + +After the record page payload answered, a record open still sent the comment thread, the activity feed, the attachment list, the mention list and one `?top=1` name read per object the related rows point at. They are gone from the open: + +- The page asks the payload for the discussion and the 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 for the 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. There is no fallback read: a part the payload carries is never read again on the open. +- Each related list asks the lookups of its rows to resolve, as its own read expands them, so the rows carry their names. The related list's name fallback no longer resolves the parent record's own id, which no column draws. +- The mention list (`sys_user`, 50 rows) is read on the comment composer's first focus, not when the record opens. A reply or an edit whose text holds an `@` before then reads it when written, so its mentions still resolve. + +- `@object-ui/types`: `RecordPagePayload` gains `discussion`, `activity` and `attachments`. +- `@object-ui/react`: `DiscussionContextValue.onMentionSuggestionsWanted`. +- `@object-ui/plugin-detail`: `RichTextCommentInput`, `RecordActivityTimeline` and `RecordChatterPanel` take `onMentionSuggestionsWanted`, called on the editor's focus; `record:activity` and `record:chatter` pass the context's hook. diff --git a/.changeset/28470-record-page-streams.md b/.changeset/28470-record-page-streams.md new file mode 100644 index 00000000000..2de77b45294 --- /dev/null +++ b/.changeset/28470-record-page-streams.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +--- + +feat(rest): the record page payload carries the record's discussion, activity and attachments, and the related rows' names (#28470) + +Clause-โ‘ก: yes (widening) +Metadata-protocol: none + +`POST /api/v1/data/{object}/{id}/page` answers the reads a record page still made on its own after the payload arrived. Each new part is the read it replaces, asked as the same caller behind that object's own exposure gate, and each is refused on its own: + +- **`related[].expand`** (request): the lookups of a related list's rows to resolve, as that list read's own `$expand`. The rows carry the referenced records the caller may read in place of their ids, so no name lookup follows the payload. +- **`discussion: true`** (request) โ†’ **`discussion`** (response): `{ comments, reactions }` โ€” the record's comment thread (`sys_comment` where `thread_id` is `{object}:{id}`, oldest first, no page size) and those comments' `sys_comment_reaction` rows (oldest first). The reactions carry their own refusal, which leaves the comments answered. +- **`activity: { top }`** (request, 1โ€“200) โ†’ **`activity`** (response): `{ total, records }` โ€” the record's newest `sys_activity` rows with their count, `actor_id` expanded. +- **`attachments: { top }`** (request, 0โ€“100) โ†’ **`attachments`** (response): `{ total, records }` โ€” the record's newest `sys_attachment` rows with their count. + +A stream part is its rows or its refusal (`RecordPageStreamRefusalSchema`), never both and never neither. The body stays strict, the query-parameter set stays empty, and a record the caller cannot read still refuses the whole request. New exports in `@objectstack/spec/api`: `RecordPageActivityRequestSchema`, `RecordPageAttachmentsRequestSchema`, `RecordPageStreamRowsSchema`, `RecordPageStreamRefusalSchema`, `RecordPageDiscussionSchema`, `RecordPageDiscussionReactionsSchema`, `RECORD_PAGE_MAX_ACTIVITY_ROWS`, `RECORD_PAGE_MAX_ATTACHMENT_ROWS`, and their types. diff --git a/content/docs/references/api/record-page.mdx b/content/docs/references/api/record-page.mdx index 14fb3a9dd7d..6ff1f240e7b 100644 --- a/content/docs/references/api/record-page.mdx +++ b/content/docs/references/api/record-page.mdx @@ -1,7 +1,7 @@ --- title: Record Page schema โ€” API Protocol reference navTitle: Record Page -description: "Record page payload โ€” POST /api/v1/data/{object}/{id}/page. Reference for RecordPageApprovals and 10 more: every property with its type and default." +description: "Record page payload โ€” POST /api/v1/data/{object}/{id}/page. Reference for RecordPageActivityRequest and 16 more: every property with its type and default." --- {/* โš ๏ธ AUTO-GENERATED โ€” DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -10,9 +10,11 @@ Record page payload โ€” `POST /api/v1/data/{object}/{id}/page`. One request that answers everything a record page asks of the server when it opens: the record (with the lookups it names resolved), the count and first -rows of every related list the page draws, the caller's field projection of -the record, the approval state that locks it, and the record-grained access -verdicts its header actions are gated on. +rows of every related list the page draws (with the lookups of those rows it +names resolved), the caller's field projection of the record, the approval +state that locks it, the record-grained access verdicts its header actions +are gated on, and the record's own streams the page draws โ€” its discussion, +its activity feed and its attachments. The request NAMES the parts. Which related lists a page draws (their order, their declared scope, which of them the reader may see) is the page's own @@ -24,15 +26,24 @@ declared `filter` โ€” a declared filter can only narrow it. Every part is the answer of the read it replaces, asked as the same caller: the record is `GET /data/{object}/{id}`, a related list is -`GET /data/{child}` with that filter and `$count`, `fieldAccess` is -`GET /data/{object}/{id}/field-access`, `explain` is -`POST /security/explain` with the record id, and `approvals` is +`GET /data/{child}` with that filter, `$count` and the `$expand` it names, +`fieldAccess` is `GET /data/{object}/{id}/field-access`, `explain` is +`POST /security/explain` with the record id, `approvals` is `GET /approvals/requests` for the record plus -`GET /approvals/records/{object}/{id}/lock`. Object permissions, row-level -security, sharing and field-level security therefore apply exactly as they -apply to those reads. A part whose read refuses carries that refusal in place -of an answer โ€” a related list the caller may not read carries neither a -count nor rows โ€” and the other parts are answered. A record the caller +`GET /approvals/records/{object}/{id}/lock`, `discussion` is +`GET /data/sys_comment` for the record's thread plus +`GET /data/sys_comment_reaction` for those comments, `activity` is +`GET /data/sys_activity` for the record with its actor expanded, and +`attachments` is `GET /data/sys_attachment` for the record with `$count`. +The three stream parts are each that read exactly as the table on +`RecordPageActivityRequestSchema` spells it: the route composes the record's +scope and the order, and the request names only whether a stream is wanted +and how many rows. Paging further, posting, reacting and uploading keep +their own routes. Object permissions, row-level security, sharing and +field-level security therefore apply exactly as they apply to those reads. +A part whose read refuses carries that refusal in place of an answer โ€” a +related list the caller may not read carries neither a count nor rows โ€” and +the other parts are answered. A record the caller cannot read refuses the whole request with the record read's own answer, so no part discloses a record the caller could not open. @@ -43,13 +54,24 @@ no part discloses a record the caller could not open. ## TypeScript Usage ```typescript -import { RecordPageApprovalsSchema, RecordPageExplainAnswerSchema, RecordPageExplainOperationSchema, RecordPagePartErrorSchema, RecordPagePartRefusalSchema, RecordPageRelatedAnswerSchema, RecordPageRelatedRefusalSchema, RecordPageRelatedRequestSchema, RecordPageRelatedRowsSchema, RecordPageRequestSchema, RecordPageResponseSchema } from '@objectstack/spec/api'; -import type { RecordPageApprovals, RecordPageExplainAnswer, RecordPageExplainOperation, RecordPagePartError, RecordPagePartRefusal, RecordPageRelatedAnswer, RecordPageRelatedRefusal, RecordPageRelatedRequest, RecordPageRelatedRows, RecordPageRequest, RecordPageResponse } from '@objectstack/spec/api'; +import { RecordPageActivityRequestSchema, RecordPageApprovalsSchema, RecordPageAttachmentsRequestSchema, RecordPageDiscussionSchema, RecordPageDiscussionReactionsSchema, RecordPageExplainAnswerSchema, RecordPageExplainOperationSchema, RecordPagePartErrorSchema, RecordPagePartRefusalSchema, RecordPageRelatedAnswerSchema, RecordPageRelatedRefusalSchema, RecordPageRelatedRequestSchema, RecordPageRelatedRowsSchema, RecordPageRequestSchema, RecordPageResponseSchema, RecordPageStreamRefusalSchema, RecordPageStreamRowsSchema } from '@objectstack/spec/api'; +import type { RecordPageActivityRequest, RecordPageApprovals, RecordPageAttachmentsRequest, RecordPageDiscussion, RecordPageDiscussionReactions, RecordPageExplainAnswer, RecordPageExplainOperation, RecordPagePartError, RecordPagePartRefusal, RecordPageRelatedAnswer, RecordPageRelatedRefusal, RecordPageRelatedRequest, RecordPageRelatedRows, RecordPageRequest, RecordPageResponse, RecordPageStreamRefusal, RecordPageStreamRows } from '@objectstack/spec/api'; // Validate data -const result = RecordPageApprovalsSchema.parse(data); +const result = RecordPageActivityRequestSchema.parse(data); ``` +--- + +## RecordPageActivityRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **top** | `integer` | โœ… | How many of the record's newest activity rows to return with their count. | + + --- ## RecordPageApprovals @@ -62,6 +84,78 @@ const result = RecordPageApprovalsSchema.parse(data); | **lock** | `{ locked: boolean } \| null` | โœ… | Whether a locking approval holds the record; null when the deployment answers no lock. | +--- + +## RecordPageAttachmentsRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **top** | `integer` | โœ… | How many of the record's newest attachments to return with their count; 0 returns the count alone. | + + +--- + +## RecordPageDiscussion + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **comments** | `Record[]` | โœ… | The thread's comments, oldest first, as the caller may read them. | +| **reactions** | `{ records: Record[] } \| { error: object }` | โœ… | The reactions of those comments, or the refusal of their read. | + +### Nested Shape: `RecordPageDiscussion.reactions[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **records** | `Record[]` | โœ… | The `sys_comment_reaction` rows of the thread's comments, oldest first; empty for a thread with no comment. | + +### Nested Shape: `RecordPageDiscussion.reactions[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + + +--- + +## RecordPageDiscussionReactions + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **records** | `Record[]` | โœ… | The `sys_comment_reaction` rows of the thread's comments, oldest first; empty for a thread with no comment. | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageDiscussionReactions[option 2].error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | โœ… | The error code the replaced read answers. | +| **status** | `integer` | โœ… | The HTTP status the replaced read answers. | +| **message** | `string` | โœ… | The message the replaced read answers. | + +--- + + --- ## RecordPageExplainAnswer @@ -263,6 +357,7 @@ This schema accepts one of the following structures: | **filter** | `any` | optional | The list's own declared scope, in the filter syntax `GET /data/{object}` accepts. AND-composed with the parent condition, so it can only narrow the list. A filter that syntax refuses refuses the whole request (a located 400 at `related.N.filter`) before anything is read. | | **top** | `integer` | โœ… | How many of the list's first rows to return with its count; 0 returns the count alone. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | The order of the list's first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order. | +| **expand** | `string[]` | optional | Lookup fields of the child object to resolve on the list's rows, as `GET /data/{object}?$expand=` resolves them: each named reference is answered with the referenced record the caller may read in place of its id, so the rows carry the display names the list draws. Absent: the rows carry the bare ids. | ### Nested Shape: `RecordPageRelatedRequest.sort[number]` @@ -299,6 +394,9 @@ This schema accepts one of the following structures: | **explain** | `Enum<'update' \| 'delete' \| 'transfer'>[]` | optional | The operations to answer a record-grained access verdict for, as `POST /security/explain` answers them with this record id. | | **fieldAccess** | `boolean` | optional | Return the caller's field projection of the record, as `GET /data/{object}/{id}/field-access` does. | | **approvals** | `boolean` | optional | Return the record's approval requests and its approval lock. | +| **discussion** | `boolean` | optional | Return the record's discussion: its comment thread and those comments' reactions (`RecordPageDiscussionSchema`). | +| **activity** | `{ top: integer }` | optional | Return the record's newest activity rows and their count (`RecordPageStreamRowsSchema`). | +| **attachments** | `{ top: integer }` | optional | Return the record's newest attachments and their count (`RecordPageStreamRowsSchema`). | ### Nested Shape: `RecordPageRequest.related[number]` @@ -309,6 +407,19 @@ This schema accepts one of the following structures: | **filter** | `any` | optional | The list's own declared scope, in the filter syntax `GET /data/{object}` accepts. AND-composed with the parent condition, so it can only narrow the list. A filter that syntax refuses refuses the whole request (a located 400 at `related.N.filter`) before anything is read. | | **top** | `integer` | โœ… | How many of the list's first rows to return with its count; 0 returns the count alone. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | The order of the list's first rows, as `GET /data/{object}` orders them by `$orderby`. Absent for the default order. | +| **expand** | `string[]` | optional | Lookup fields of the child object to resolve on the list's rows, as `GET /data/{object}?$expand=` resolves them: each named reference is answered with the referenced record the caller may read in place of its id, so the rows carry the display names the list draws. Absent: the rows carry the bare ids. | + +### Nested Shape: `RecordPageRequest.activity` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **top** | `integer` | โœ… | How many of the record's newest activity rows to return with their count. | + +### Nested Shape: `RecordPageRequest.attachments` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **top** | `integer` | โœ… | How many of the record's newest attachments to return with their count; 0 returns the count alone. | --- @@ -326,6 +437,9 @@ This schema accepts one of the following structures: | **fieldAccess** | `{ object: string; recordId: string; readable: string[]; editable: string[]; โ€ฆ } \| { error: object }` | optional | The field projection, when the request asked for it. | | **explain** | `{ update?: object \| object; delete?: object \| object; transfer?: object \| object }` | optional | One access decision per requested operation, keyed by operation, when the request asked for any. | | **approvals** | `{ requests: Record[]; lock: object \| null } \| { error: object }` | optional | The approval part, when the request asked for it. | +| **discussion** | `{ comments: Record[]; reactions: object \| object } \| { error: object }` | optional | The discussion part, when the request asked for it: the thread and its reactions, or the refusal of the comment read. | +| **activity** | `{ total: integer; records: Record[] } \| { error: object }` | optional | The activity part, when the request asked for it: the newest rows and their count, or the refusal of their read. | +| **attachments** | `{ total: integer; records: Record[] } \| { error: object }` | optional | The attachments part, when the request asked for it: the newest rows and their count, or the refusal of their read. | ### Nested Shape: `RecordPageResponse.related[number][option 1]` @@ -373,6 +487,76 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | +### Nested Shape: `RecordPageResponse.discussion[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **comments** | `Record[]` | โœ… | The thread's comments, oldest first, as the caller may read them. | +| **reactions** | `{ records: Record[] } \| { error: object }` | โœ… | The reactions of those comments, or the refusal of their read. | + +### Nested Shape: `RecordPageResponse.discussion[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageResponse.activity[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **total** | `integer` | โœ… | How many rows the stream holds for this caller. | +| **records** | `Record[]` | โœ… | The stream's newest rows, as the caller may read them. | + +### Nested Shape: `RecordPageResponse.activity[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageResponse.attachments[option 1]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **total** | `integer` | โœ… | How many rows the stream holds for this caller. | +| **records** | `Record[]` | โœ… | The stream's newest rows, as the caller may read them. | + +### Nested Shape: `RecordPageResponse.attachments[option 2]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + + +--- + +## RecordPageStreamRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **error** | `{ code: string; status: integer; message: string }` | โœ… | The refusal the replaced read answers, in place of this part's answer. | + +### Nested Shape: `RecordPageStreamRefusal.error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | โœ… | The error code the replaced read answers. | +| **status** | `integer` | โœ… | The HTTP status the replaced read answers. | +| **message** | `string` | โœ… | The message the replaced read answers. | + + +--- + +## RecordPageStreamRows + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **total** | `integer` | โœ… | How many rows the stream holds for this caller. | +| **records** | `Record[]` | โœ… | The stream's newest rows, as the caller may read them. | + --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 18999f91906..ed4aeb2cbfc 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference โ€” every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec โ€” 1582 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec โ€” 1588 schemas across 14 protocol modules --- {/* โš ๏ธ AUTO-GENERATED โ€” DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 34 | 458 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 464 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers โ€” the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata โ€” the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment โ€” logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes โ€” the ObjectUI layer. | -| **Total** | **205** | **1582** | 14 protocol modules | +| **Total** | **205** | **1588** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` ยท **Import:** `@objectstack/spec/api` ยท **34 pages, 458 schemas** +**Source:** `packages/spec/src/api/` ยท **Import:** `@objectstack/spec/api` ยท **34 pages, 464 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -96,7 +96,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | -| [`record-page.zod.ts`](/docs/references/api/record-page) | `RecordPageApprovals`, `RecordPageExplainAnswer`, `RecordPageExplainOperation`, `RecordPagePartError`, `RecordPagePartRefusal`, `RecordPageRelatedAnswer`, `RecordPageRelatedRefusal`, `RecordPageRelatedRequest`, `RecordPageRelatedRows`, `RecordPageRequest`, `RecordPageResponse` | +| [`record-page.zod.ts`](/docs/references/api/record-page) | `RecordPageActivityRequest`, `RecordPageApprovals`, `RecordPageAttachmentsRequest`, `RecordPageDiscussion`, `RecordPageDiscussionReactions`, `RecordPageExplainAnswer`, `RecordPageExplainOperation`, `RecordPagePartError`, `RecordPagePartRefusal`, `RecordPageRelatedAnswer`, `RecordPageRelatedRefusal`, `RecordPageRelatedRequest`, `RecordPageRelatedRows`, `RecordPageRequest`, `RecordPageResponse`, `RecordPageStreamRefusal`, `RecordPageStreamRows` | | [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | | [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` | | [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 69617925ef6..95f1ccf5408 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** โ€” fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 472 | +| `api/` | 478 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index c402618953f..c269a380640 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -832,6 +832,8 @@ "QueryOptimizationConfig (type)", "QueryOptimizationConfigParsed (type)", "QueryOptimizationConfigSchema (const)", + "RECORD_PAGE_MAX_ACTIVITY_ROWS (const)", + "RECORD_PAGE_MAX_ATTACHMENT_ROWS (const)", "RECORD_PAGE_MAX_RELATED (const)", "RECORD_PAGE_MAX_RELATED_ROWS (const)", "RECORD_PAGE_MAX_RELATED_SORT (const)", @@ -872,9 +874,21 @@ "ReassignOrphanedMetadataResponseSchema (const)", "RecordData (type)", "RecordDataSchema (const)", + "RecordPageActivityRequest (type)", + "RecordPageActivityRequestParsed (type)", + "RecordPageActivityRequestSchema (const)", "RecordPageApprovals (type)", "RecordPageApprovalsParsed (type)", "RecordPageApprovalsSchema (const)", + "RecordPageAttachmentsRequest (type)", + "RecordPageAttachmentsRequestParsed (type)", + "RecordPageAttachmentsRequestSchema (const)", + "RecordPageDiscussion (type)", + "RecordPageDiscussionParsed (type)", + "RecordPageDiscussionReactions (type)", + "RecordPageDiscussionReactionsParsed (type)", + "RecordPageDiscussionReactionsSchema (const)", + "RecordPageDiscussionSchema (const)", "RecordPageExplainAnswer (type)", "RecordPageExplainAnswerParsed (type)", "RecordPageExplainAnswerSchema (const)", @@ -905,6 +919,12 @@ "RecordPageResponse (type)", "RecordPageResponseParsed (type)", "RecordPageResponseSchema (const)", + "RecordPageStreamRefusal (type)", + "RecordPageStreamRefusalParsed (type)", + "RecordPageStreamRefusalSchema (const)", + "RecordPageStreamRows (type)", + "RecordPageStreamRowsParsed (type)", + "RecordPageStreamRowsSchema (const)", "RefreshTokenRequest (type)", "RefreshTokenRequestSchema (const)", "RegisterDeviceRequest (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 15d3d8d0d7a..50bb8a1780f 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1451,8 +1451,12 @@ "api/ReassignOrphanedMetadataResponse:reassignedCount", "api/ReassignOrphanedMetadataResponse:success", "api/ReassignOrphanedMetadataResponse:targetPackageId", + "api/RecordPageActivityRequest:top", "api/RecordPageApprovals:lock", "api/RecordPageApprovals:requests", + "api/RecordPageAttachmentsRequest:top", + "api/RecordPageDiscussion:comments", + "api/RecordPageDiscussion:reactions", "api/RecordPagePartError:error", "api/RecordPagePartRefusal:code", "api/RecordPagePartRefusal:message", @@ -1460,6 +1464,7 @@ "api/RecordPageRelatedRefusal:error", "api/RecordPageRelatedRefusal:field", "api/RecordPageRelatedRefusal:object", + "api/RecordPageRelatedRequest:expand", "api/RecordPageRelatedRequest:field", "api/RecordPageRelatedRequest:filter", "api/RecordPageRelatedRequest:object", @@ -1469,18 +1474,27 @@ "api/RecordPageRelatedRows:object", "api/RecordPageRelatedRows:records", "api/RecordPageRelatedRows:total", + "api/RecordPageRequest:activity", "api/RecordPageRequest:approvals", + "api/RecordPageRequest:attachments", + "api/RecordPageRequest:discussion", "api/RecordPageRequest:expand", "api/RecordPageRequest:explain", "api/RecordPageRequest:fieldAccess", "api/RecordPageRequest:related", + "api/RecordPageResponse:activity", "api/RecordPageResponse:approvals", + "api/RecordPageResponse:attachments", + "api/RecordPageResponse:discussion", "api/RecordPageResponse:explain", "api/RecordPageResponse:fieldAccess", "api/RecordPageResponse:id", "api/RecordPageResponse:object", "api/RecordPageResponse:record", "api/RecordPageResponse:related", + "api/RecordPageStreamRefusal:error", + "api/RecordPageStreamRows:records", + "api/RecordPageStreamRows:total", "api/RefreshTokenRequest:refreshToken", "api/RegisterDeviceRequest:deviceId", "api/RegisterDeviceRequest:name", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 6f197c59b18..ab8bd7ee76c 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -639,8 +639,16 @@ "ReassignOrphanedMetadataResponseSchema": "api/ReassignOrphanedMetadataResponse", "RecordData": "api/RecordData", "RecordDataSchema": "api/RecordData", + "RecordPageActivityRequest": "api/RecordPageActivityRequest", + "RecordPageActivityRequestSchema": "api/RecordPageActivityRequest", "RecordPageApprovals": "api/RecordPageApprovals", "RecordPageApprovalsSchema": "api/RecordPageApprovals", + "RecordPageAttachmentsRequest": "api/RecordPageAttachmentsRequest", + "RecordPageAttachmentsRequestSchema": "api/RecordPageAttachmentsRequest", + "RecordPageDiscussion": "api/RecordPageDiscussion", + "RecordPageDiscussionReactions": "api/RecordPageDiscussionReactions", + "RecordPageDiscussionReactionsSchema": "api/RecordPageDiscussionReactions", + "RecordPageDiscussionSchema": "api/RecordPageDiscussion", "RecordPageExplainAnswer": "api/RecordPageExplainAnswer", "RecordPageExplainAnswerSchema": "api/RecordPageExplainAnswer", "RecordPageExplainOperation": "api/RecordPageExplainOperation", @@ -661,6 +669,10 @@ "RecordPageRequestSchema": "api/RecordPageRequest", "RecordPageResponse": "api/RecordPageResponse", "RecordPageResponseSchema": "api/RecordPageResponse", + "RecordPageStreamRefusal": "api/RecordPageStreamRefusal", + "RecordPageStreamRefusalSchema": "api/RecordPageStreamRefusal", + "RecordPageStreamRows": "api/RecordPageStreamRows", + "RecordPageStreamRowsSchema": "api/RecordPageStreamRows", "RefreshTokenRequest": "api/RefreshTokenRequest", "RefreshTokenRequestSchema": "api/RefreshTokenRequest", "RegisterDeviceRequest": "api/RegisterDeviceRequest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index e29d11666b9..47a22a2a75a 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -803,6 +803,8 @@ "QueryOptimizationConfig": "src/api/contract.zod.ts#QueryOptimizationConfig (type)", "QueryOptimizationConfigParsed": "src/api/contract.zod.ts#QueryOptimizationConfigParsed (type)", "QueryOptimizationConfigSchema": "src/api/contract.zod.ts#QueryOptimizationConfigSchema (const)", + "RECORD_PAGE_MAX_ACTIVITY_ROWS": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_ACTIVITY_ROWS (const)", + "RECORD_PAGE_MAX_ATTACHMENT_ROWS": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_ATTACHMENT_ROWS (const)", "RECORD_PAGE_MAX_RELATED": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED (const)", "RECORD_PAGE_MAX_RELATED_ROWS": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_ROWS (const)", "RECORD_PAGE_MAX_RELATED_SORT": "src/api/record-page.zod.ts#RECORD_PAGE_MAX_RELATED_SORT (const)", @@ -841,9 +843,21 @@ "ReassignOrphanedMetadataResponseSchema": "src/api/package-lifecycle.zod.ts#ReassignOrphanedMetadataResponseSchema (const)", "RecordData": "src/api/contract.zod.ts#RecordData (type)", "RecordDataSchema": "src/api/contract.zod.ts#RecordDataSchema (const)", + "RecordPageActivityRequest": "src/api/record-page.zod.ts#RecordPageActivityRequest (type)", + "RecordPageActivityRequestParsed": "src/api/record-page.zod.ts#RecordPageActivityRequestParsed (type)", + "RecordPageActivityRequestSchema": "src/api/record-page.zod.ts#RecordPageActivityRequestSchema (const)", "RecordPageApprovals": "src/api/record-page.zod.ts#RecordPageApprovals (type)", "RecordPageApprovalsParsed": "src/api/record-page.zod.ts#RecordPageApprovalsParsed (type)", "RecordPageApprovalsSchema": "src/api/record-page.zod.ts#RecordPageApprovalsSchema (const)", + "RecordPageAttachmentsRequest": "src/api/record-page.zod.ts#RecordPageAttachmentsRequest (type)", + "RecordPageAttachmentsRequestParsed": "src/api/record-page.zod.ts#RecordPageAttachmentsRequestParsed (type)", + "RecordPageAttachmentsRequestSchema": "src/api/record-page.zod.ts#RecordPageAttachmentsRequestSchema (const)", + "RecordPageDiscussion": "src/api/record-page.zod.ts#RecordPageDiscussion (type)", + "RecordPageDiscussionParsed": "src/api/record-page.zod.ts#RecordPageDiscussionParsed (type)", + "RecordPageDiscussionReactions": "src/api/record-page.zod.ts#RecordPageDiscussionReactions (type)", + "RecordPageDiscussionReactionsParsed": "src/api/record-page.zod.ts#RecordPageDiscussionReactionsParsed (type)", + "RecordPageDiscussionReactionsSchema": "src/api/record-page.zod.ts#RecordPageDiscussionReactionsSchema (const)", + "RecordPageDiscussionSchema": "src/api/record-page.zod.ts#RecordPageDiscussionSchema (const)", "RecordPageExplainAnswer": "src/api/record-page.zod.ts#RecordPageExplainAnswer (type)", "RecordPageExplainAnswerParsed": "src/api/record-page.zod.ts#RecordPageExplainAnswerParsed (type)", "RecordPageExplainAnswerSchema": "src/api/record-page.zod.ts#RecordPageExplainAnswerSchema (const)", @@ -874,6 +888,12 @@ "RecordPageResponse": "src/api/record-page.zod.ts#RecordPageResponse (type)", "RecordPageResponseParsed": "src/api/record-page.zod.ts#RecordPageResponseParsed (type)", "RecordPageResponseSchema": "src/api/record-page.zod.ts#RecordPageResponseSchema (const)", + "RecordPageStreamRefusal": "src/api/record-page.zod.ts#RecordPageStreamRefusal (type)", + "RecordPageStreamRefusalParsed": "src/api/record-page.zod.ts#RecordPageStreamRefusalParsed (type)", + "RecordPageStreamRefusalSchema": "src/api/record-page.zod.ts#RecordPageStreamRefusalSchema (const)", + "RecordPageStreamRows": "src/api/record-page.zod.ts#RecordPageStreamRows (type)", + "RecordPageStreamRowsParsed": "src/api/record-page.zod.ts#RecordPageStreamRowsParsed (type)", + "RecordPageStreamRowsSchema": "src/api/record-page.zod.ts#RecordPageStreamRowsSchema (const)", "RefreshTokenRequest": "src/api/auth.zod.ts#RefreshTokenRequest (type)", "RefreshTokenRequestSchema": "src/api/auth.zod.ts#RefreshTokenRequestSchema (const)", "RegisterDeviceRequest": "src/api/protocol.zod.ts#RegisterDeviceRequest (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 57ad289451b..c5a6ba34523 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -348,7 +348,11 @@ "api/RealtimeUnsubscribeResponse", "api/ReassignOrphanedMetadataResponse", "api/RecordData", + "api/RecordPageActivityRequest", "api/RecordPageApprovals", + "api/RecordPageAttachmentsRequest", + "api/RecordPageDiscussion", + "api/RecordPageDiscussionReactions", "api/RecordPageExplainAnswer", "api/RecordPageExplainOperation", "api/RecordPagePartError", @@ -359,6 +363,8 @@ "api/RecordPageRelatedRows", "api/RecordPageRequest", "api/RecordPageResponse", + "api/RecordPageStreamRefusal", + "api/RecordPageStreamRows", "api/RefreshTokenRequest", "api/RegisterDeviceRequest", "api/RegisterDeviceResponse", diff --git a/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx b/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx index 634f722ebf1..0fcc42f8ba8 100644 --- a/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx +++ b/ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx @@ -7,7 +7,7 @@ */ import * as React from 'react'; -import { cn, Button } from '@object-ui/components'; +import { cn, Button, RelatedCountStore } from '@object-ui/components'; import { Paperclip, Upload, @@ -25,6 +25,7 @@ import { useObjectTranslation, isPermissionError, classifyLoadError } from '@obj import { usePermissions } from '@object-ui/permissions'; import { resolveAffordance, type ConfirmationHandler } from '@object-ui/core'; import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog.js'; +import { RECORD_PAGE_ATTACHMENT_ROWS, recordAttachmentsCountField } from './recordPagePayload.js'; /** * RecordAttachmentsPanel โ€” generic record Attachments surface (objectstack-ai/objectstack#2727, @@ -264,11 +265,24 @@ export const RecordAttachmentsPanel: React.FC = ({ if (!dataSource || !objectName || !recordId) return; setStatus('loading'); try { - const res: any = await dataSource.find('sys_attachment', { - $filter: { parent_object: objectName, parent_id: recordId }, - $orderby: { created_at: 'desc' }, - $top: 100, - }); + // objectstack#28470 โ€” the record page payload carries this read's + // answer on the record's open (`seedRecordPageReaders`, under the key + // the Attachments tab badge counts): the first read takes it, once, and + // every later one (an upload, a delete, Retry) is the panel's own. + const seeded = RelatedCountStore.takeRows( + 'sys_attachment', + recordAttachmentsCountField(objectName), + recordId, + undefined, + RECORD_PAGE_ATTACHMENT_ROWS, + ); + const res: any = seeded + ? { data: (await seeded).rows } + : await dataSource.find('sys_attachment', { + $filter: { parent_object: objectName, parent_id: recordId }, + $orderby: { created_at: 'desc' }, + $top: RECORD_PAGE_ATTACHMENT_ROWS, + }); const items: AttachmentRow[] = Array.isArray(res) ? res : res?.data ?? []; // objectui#10684 โ€” a superseded answer is dropped on arrival. if (!isCurrent()) return; diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx new file mode 100644 index 00000000000..0d6754a9dc8 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenOneRequest-28470.test.tsx @@ -0,0 +1,284 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28470 โ€” a record open is ONE request: the record's streams and + * the related rows' names ride in the page payload. + * + * Measured on main before this change (the console over `examples/app-crm`, + * a production build): opening a contact sent the page payload, then the + * discussion (`sys_comment`), the activity (`sys_activity`), the mention list + * (`sys_user?top=50`) and one `?top=1` name read per object the related rows + * point at โ€” the related list's name fallback resolving the payload's bare + * ids, the parent's own id among them. + * + * Pinned here, through the real adapter under StrictMode: one page request and + * no `sys_comment`, `sys_comment_reaction`, `sys_activity`, `sys_attachment`, + * `sys_user?top=50` or `?top=1` read; the feed, the attachments and the badge + * draw the payload's parts; the mention list is read on the composer's first + * focus; a refused part draws its refusal and is not read again. The ablations + * show the detectors see each retired read when its part is not taken. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; +import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; +import { RecordDetailView } from './RecordDetailView'; +import { recordAttachmentsCountField } from './recordPagePayload'; +// The `record:attachments` block the synthesized page places for `enable.files` +// (registered by the package entry, which this file does not import). +import './record-attachments-renderer'; + +const BASE = 'http://test.local'; +const OBJECT = 'os_28470_contact'; +const CHILD = 'os_28470_task'; + +const contactDef = { + name: OBJECT, + label: 'Contact', + enable: { files: true }, + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + account: { label: 'Account', type: 'lookup', reference: 'os_28470_account' }, + }, +}; +const accountDef = { name: 'os_28470_account', label: 'Account', fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } } }; +const childDef = { + name: CHILD, + label: 'Task', + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + contact: { label: 'Contact', type: 'lookup', reference: OBJECT }, + owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' }, + }, +}; +const ALL = [contactDef, accountDef, childDef]; + +const COMMENT = { id: 'cm1', thread_id: `${OBJECT}:c1`, author_id: 'u2', author_name: 'Grace', body: 'hello from the payload', created_at: '2026-10-01T10:00:00.000Z' }; +const ACTIVITY = { id: 'ac1', type: 'created', object_name: OBJECT, record_id: 'c1', actor_name: 'Grace', summary: 'created the contact', timestamp: '2026-09-30T10:00:00.000Z' }; +const ATTACHMENT = { id: 'f1', parent_object: OBJECT, parent_id: 'c1', file_id: 'file-1', file_name: 'contract.pdf', created_at: '2026-10-02T10:00:00.000Z' }; + +interface Answers { + /** Answer the related rows' owner as a bare id even when the request expands it. */ + ignoreExpand?: boolean; + activity?: unknown; +} + +interface Held { method: string; path: string; body: any; released: boolean; release: () => void } + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +function pageAnswer(id: string, body: any, answers: Answers) { + return { + success: true, + data: { + object: OBJECT, + id, + record: { id, name: `Contact ${id}`, account: 'a1' }, + related: (body.related ?? []).map((r: any) => ({ + object: r.object, + field: r.field, + total: 2, + records: [0, 1].slice(0, r.top).map((i) => ({ + id: `t${i}`, + name: `Task ${i}`, + contact: id, + owner: !answers.ignoreExpand && r.expand?.includes('owner') ? { id: 'u9', name: 'Una Owner' } : 'u9', + })), + })), + ...(body.fieldAccess ? { fieldAccess: { object: OBJECT, recordId: id, readable: ['name', 'account'], editable: ['name'], writable: true } } : {}), + ...(body.explain ? { explain: Object.fromEntries(body.explain.map((op: string) => [op, { allowed: true, record: { recordId: id, visible: true } }])) } : {}), + ...(body.approvals ? { approvals: { requests: [], lock: { locked: false } } } : {}), + ...(body.discussion ? { discussion: { comments: [COMMENT], reactions: { records: [] } } } : {}), + ...(body.activity ? { activity: answers.activity ?? { total: 1, records: [ACTIVITY] } } : {}), + ...(body.attachments ? { attachments: { total: 3, records: [ATTACHMENT] } } : {}), + }, + }; +} + +function answerFor(h: Held, answers: Answers): unknown { + const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path); + if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }; + const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path); + if (page) return pageAnswer(page[1], h.body, answers); + const one = new RegExp(`/api/v1/data/${OBJECT}\\?.*\\["id","=","([^"]+)"\\]`).exec(h.path); + if (one) return { success: true, data: { records: [{ id: one[1], name: `Contact ${one[1]}`, account: 'a1' }], total: 1 } }; + if (h.path.startsWith('/api/v1/data/sys_user?')) return { success: true, data: { records: [{ id: 'u9', name: 'Una Owner' }], total: 1 } }; + return { success: true, data: { records: [], total: 0 } }; +} + +function heldTransport(answers: Answers) { + const held: Held[] = []; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + const path = decodeURIComponent(url.replace(BASE, '')); + if (path.endsWith('/api/v1/discovery')) return Promise.resolve(json({ success: true, data: { capabilities: {}, routes: {} } })); + return new Promise((resolve) => { + const h: Held = { + method, path, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + released: false, + release: () => { if (h.released) return; h.released = true; resolve(json(answerFor(h, answers))); }, + }; + held.push(h); + }); + }); + return { fetch, held }; +} + +async function idle() { + await act(async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); }); +} +async function settle(held: Held[]) { + await idle(); + for (let round = 0; round < 12; round++) { + const pending = held.filter((h) => !h.released); + if (pending.length === 0) return; + await act(async () => { for (const h of pending) h.release(); await new Promise((r) => setTimeout(r, 0)); }); + await idle(); + } +} + +const isMeta = (h: Held) => h.path.startsWith('/api/v1/meta/'); +const isPage = (h: Held) => h.method === 'POST' && /\/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(h.path); +const readsOf = (held: Held[], object: string) => held.filter((h) => h.method === 'GET' && h.path.startsWith(`/api/v1/data/${object}?`)); +/** The retired stream reads, by object. */ +const STREAMS = ['sys_comment', 'sys_comment_reaction', 'sys_activity', 'sys_attachment']; +const streamReads = (held: Held[]) => STREAMS.flatMap((o) => readsOf(held, o).map(() => o)); +const mentionReads = (held: Held[]) => readsOf(held, 'sys_user').filter((h) => /[?&]\$?top=50\b/.test(h.path)); +const nameProbes = (held: Held[]) => held.filter((h) => h.method === 'GET' && /[?&]\$?top=1\b/.test(h.path)); +const probedObject = (h: Held) => /\/api\/v1\/data\/([^/?]+)\?/.exec(h.path)![1]; + +beforeEach(() => { + cleanup(); + __clearRecordEditableCache(); + __resetRecordApprovalsStore(); + RelatedCountStore._reset(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +async function boot(answers: Answers = {}) { + const { fetch, held } = heldTransport(answers); + vi.stubGlobal('fetch', fetch); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + const metadata = { + objects: ALL, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + const open = () => render( + + + + {}} objectNameOverride={OBJECT} recordIdOverride="c1" /> + + + , + ); + return { held, open, ds }; +} + +describe('objectstack#28470 โ€” one record open is one request', () => { + it('sends exactly one page request and no stream, attachment, mention or name read', async () => { + const { held, open } = await boot(); + const view = open(); + await settle(held); + + const data = held.filter((h) => !isMeta(h)); + expect(data.map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); + expect(streamReads(held)).toEqual([]); + expect(mentionReads(held)).toEqual([]); + expect(nameProbes(held)).toEqual([]); + + // The request names what the page draws: the streams, and each related + // list's lookups but the parent's. + const [page] = held.filter(isPage); + expect(page.body).toMatchObject({ discussion: true, activity: { top: 200 }, attachments: { top: 100 } }); + expect(page.body.related).toEqual([expect.objectContaining({ object: CHILD, field: 'contact', expand: ['owner'] })]); + + // Every part is drawn from the payload: the comment, the attachment row + // and its badge's count. + expect(view.container.textContent).toContain('hello from the payload'); + expect(view.container.textContent).toContain('contract.pdf'); + expect(RelatedCountStore.get('sys_attachment', recordAttachmentsCountField(OBJECT), 'c1')).toBe(3); + }); + + it('reads the mention list on the composer\'s first focus, once', async () => { + const { held, open } = await boot(); + const view = open(); + await settle(held); + expect(mentionReads(held)).toEqual([]); + + const standIn = view.container.querySelector('[data-record-timeline-composer]') as HTMLElement | null; + expect(standIn, 'the ๅŠจๆ€ panel draws its one-line composer').not.toBeNull(); + await act(async () => { fireEvent.click(standIn!); }); + await settle(held); + expect(mentionReads(held)).toHaveLength(1); + + const editor = view.container.querySelector('[data-record-timeline-peek] textarea') as HTMLTextAreaElement; + await act(async () => { fireEvent.blur(editor); fireEvent.focus(editor); }); + await settle(held); + expect(mentionReads(held)).toHaveLength(1); + }); + + it('a refused activity part draws the refusal, the discussion still draws, and nothing is read again', async () => { + const { held, open } = await boot({ activity: { error: { code: 'PERMISSION_DENIED', status: 403, message: 'No.' } } }); + const view = open(); + await settle(held); + expect(view.container.querySelector('[data-denied-source="activity"]')).not.toBeNull(); + expect(view.container.textContent).toContain('hello from the payload'); + expect(streamReads(held)).toEqual([]); + }); + + // โ”€โ”€ Ablations: the detectors above see each retired read โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it('ablation โ€” an adapter without the payload reads each stream on its own, and the detectors see them', async () => { + const { held, open, ds } = await boot(); + Object.assign(ds, { getRecordPage: undefined, prefetchRecordPage: undefined }); + open(); + await settle(held); + expect(held.filter(isPage)).toEqual([]); + expect([...new Set(streamReads(held))].sort()).toEqual(['sys_activity', 'sys_attachment', 'sys_comment']); + }); + + it('ablation โ€” rows answered with bare ids pay one name read per referenced object, never the parent\'s', async () => { + const { held, open } = await boot({ ignoreExpand: true }); + open(); + await settle(held); + expect(held.filter(isPage)).toHaveLength(1); + expect(nameProbes(held).map(probedObject)).toEqual(['sys_user']); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index 4bbe8ade0b6..dbebb02cc11 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -26,7 +26,10 @@ * Since objectstack#27627 the page asks all of it in ONE record page payload * (`POST /data/OBJ/ID/page`): the record, each related list's count and first * rows, and the access verdicts. The pins below read that request's body for - * the questions the separate reads used to ask. + * the questions the separate reads used to ask. Since objectstack#28470 the + * payload also carries the feed (comments and activity) when a block that + * draws it is on screen at open, and the attachments when the page draws them; + * the mention list waits for the composer's focus. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -215,7 +218,22 @@ const isPageRead = (h: Held) => h.method === 'POST' && h.path === PAGE; /** The record read: the page payload carries it (objectstack#27627). */ const isRecordRead = (h: Held) => isPageRead(h) || (h.method === 'GET' && objectOf(h) === OBJECT); interface PageList { object: string; field: string; filter?: unknown; top: number } -const pageBody = (h: Held) => JSON.parse(h.body || '{}') as { related?: PageList[]; explain?: string[] }; +const pageBody = (h: Held) => JSON.parse(h.body || '{}') as { + related?: PageList[]; + explain?: string[]; + discussion?: boolean; + activity?: { top: number }; + attachments?: { top: number }; +}; +/** The stream parts the open's page payload asked for (objectstack#28470). */ +const pageStreams = (held: Held[]) => { + const body = held.filter(isPageRead).map(pageBody)[0] ?? {}; + return { + discussion: body.discussion === true, + activity: body.activity?.top, + attachments: body.attachments?.top, + }; +}; /** The related lists the open's page payload asked for. */ const pageLists = (held: Held[]): PageList[] => held.filter(isPageRead).flatMap((h) => pageBody(h).related ?? []); const isChildRead = (h: Held) => h.method === 'GET' && [QUOTE, LINE, TASK].includes(objectOf(h) ?? ''); @@ -236,6 +254,10 @@ function answerFor(h: Held): unknown { }), explain: Object.fromEntries((body.explain ?? []).map((op) => [op, { allowed: true, record: { recordId: REC, visible: true } }])), approvals: { requests: [], lock: null }, + // objectstack#28470 โ€” the stream parts, the rows the separate reads answer. + ...(body.discussion ? { discussion: { comments: FEED_ROWS.sys_comment, reactions: { records: [] } } } : {}), + ...(body.activity ? { activity: { total: FEED_ROWS.sys_activity.length, records: FEED_ROWS.sys_activity } } : {}), + ...(body.attachments ? { attachments: { total: FEED_ROWS.sys_attachment.length, records: FEED_ROWS.sys_attachment } } : {}), }, }; } @@ -501,6 +523,7 @@ const isActivityRead = isPanelRead('sys_activity'); const isCommentRead = isPanelRead('sys_comment'); const isUserListRead = (h: Held) => isPanelRead('sys_user')(h) && topOf(h) === 50; const isAttachmentListRead = (h: Held) => isPanelRead('sys_attachment')(h) && topOf(h) === 100; +const isAttachmentRead = isPanelRead('sys_attachment'); /** The `record:activity` block's node, as `SchemaRenderer` marks it in the DOM. */ const ACTIVITY_MOUNT = '[data-obj-id="deal_activity"]'; const FOUR_READS = [isActivityRead, isCommentRead, isUserListRead, isAttachmentListRead]; @@ -562,23 +585,28 @@ describe('the timeline, comments, attachments and user list wait for their panel expect(fallback?.textContent ?? '', table).not.toContain('signed-contract.pdf'); }); - it('opening the Activity tab reads the timeline, the comments and the user list once each, and draws their rows', async () => { + // The open's payload did not ask the feed (its block was not on screen), so + // the tab's reads are its own, made when it opens โ€” and the mention list + // still waits for the composer's focus (objectstack#28470). + it('opening the Activity tab reads the timeline and the comments once each, and draws their rows', async () => { const { held, state } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver }); + expect(pageStreams(held), waveTable(held)).toMatchObject({ discussion: false, activity: undefined }); // Everything the open asked for is answered; the tab's reads come after. const openedWith = held.length; await openTab('Activity'); await runWaves(held, state); const table = waveTable(held); - for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { + for (const isRead of [isActivityRead, isCommentRead]) { const reads = held.filter(isRead); expect(reads, table).toHaveLength(1); expect(reads[0].seq, table).toBeGreaterThanOrEqual(openedWith); } + expect(held.filter(isUserListRead), table).toEqual([]); expect(held.filter(isActivityRead)[0].path, table).toContain('top=200'); expect(screen.getByText('Stage moved to proposal')).toBeTruthy(); expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy(); // The attachments list is not the Activity tab's: still unread. - expect(held.filter(isAttachmentListRead), table).toEqual([]); + expect(held.filter(isAttachmentRead), table).toEqual([]); }); // Drift pin: "not read" holds only because the block is not MOUNTED. A tab @@ -596,7 +624,7 @@ describe('the timeline, comments, attachments and user list wait for their panel // An authored `properties.defaultTab` beats `?tab=` in the renderer (the // `properties` hoist runs after `withPageTabsUrlSync` writes `?tab=`), so the // view may not judge such a strip by `?tab=`: it reads the feed at open. - it('a strip whose properties.defaultTab names the Activity tab still reads the feed when ?tab= names another tab', async () => { + it('a strip whose properties.defaultTab names the Activity tab still asks the feed when ?tab= names another tab', async () => { const page = { ...ACTIVITY_PAGE, regions: [ @@ -606,40 +634,49 @@ describe('the timeline, comments, attachments and user list wait for their panel }; const { held } = await openRecordPage({ page, object: filesObject, observer: FoldObserver, search: '?tab=details' }); const table = waveTable(held); - for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toHaveLength(1); + // The payload carries it (objectstack#28470): no read of its own. + expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 200 }); + for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toEqual([]); expect(document.querySelector(ACTIVITY_MOUNT), table).not.toBeNull(); expect(screen.getByText('Stage moved to proposal')).toBeTruthy(); expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy(); }); - it('the foot-of-page Attachments panel reads once it scrolls into view, and draws its rows', async () => { + // objectstack#28470 โ€” the payload carries the attachments: the panel takes + // them once it scrolls into view, and reads nothing of its own. + it('the foot-of-page Attachments panel draws the payload\'s rows once it scrolls into view, and reads nothing', async () => { const { held, state } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver }); - expect(held.filter(isAttachmentListRead), waveTable(held)).toEqual([]); + expect(pageStreams(held).attachments, waveTable(held)).toBe(100); + expect(held.filter(isAttachmentRead), waveTable(held)).toEqual([]); await scrollFallbackIntoView(); await runWaves(held, state); const table = waveTable(held); - expect(held.filter(isAttachmentListRead), table).toHaveLength(1); + expect(held.filter(isAttachmentRead), table).toEqual([]); expect(screen.getByText('signed-contract.pdf')).toBeTruthy(); }); - it('CONTROL: a record opened on its Activity tab reads all three at open, and draws the same rows', async () => { + it('CONTROL: a record opened on its Activity tab asks the feed with its page at open, and draws the same rows', async () => { const { held } = await openRecordPage({ page: ACTIVITY_PAGE, object: filesObject, observer: FoldObserver, search: '?tab=activity' }); const table = waveTable(held); // The mount marker the drift pin below reads is real: present when the tab is. expect(document.querySelector(ACTIVITY_MOUNT), table).not.toBeNull(); + expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 200 }); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { - expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); + expect(held.filter(isRead), table).toEqual([]); } expect(screen.getByText('Stage moved to proposal')).toBeTruthy(); expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy(); }); - it('CONTROL: a record opened on its Attachments tab reads the list at open; the tab badge probe goes out either way', async () => { + // objectstack#28470 โ€” the payload carries the list and its count, so the + // tab badge draws the count without a probe and the panel its rows without + // a read, whichever tab the record opens on. + it('CONTROL: a record opened on its Attachments tab draws the payload\'s rows; the tab badge counts them either way, with no read', async () => { const closed = await openRecordPage({ page: FILES_TAB_PAGE, object: filesObject, observer: FoldObserver }); const closedTable = waveTable(closed.held); - expect(closed.held.filter(isAttachmentListRead), closedTable).toEqual([]); - const closedProbes = closed.held.filter((h) => isPanelRead('sys_attachment')(h) && topOf(h) === 1); - expect(closedProbes.map((h) => h.wave), closedTable).toEqual([1]); + expect(pageStreams(closed.held).attachments, closedTable).toBe(100); + expect(closed.held.filter(isAttachmentRead), closedTable).toEqual([]); + expect(RelatedCountStore.get('sys_attachment', `attachments:${OBJECT}`, REC), closedTable).toBe(FEED_ROWS.sys_attachment.length); cleanup(); RelatedCountStore._reset(); @@ -648,8 +685,8 @@ describe('the timeline, comments, attachments and user list wait for their panel const open = await openRecordPage({ page: FILES_TAB_PAGE, object: filesObject, observer: FoldObserver, search: '?tab=attachments' }); const openTable = waveTable(open.held); - expect(open.held.filter(isAttachmentListRead).map((h) => h.wave), openTable).toEqual([1]); - expect(open.held.filter((h) => isPanelRead('sys_attachment')(h) && topOf(h) === 1).map((h) => h.wave), openTable).toEqual([1]); + expect(open.held.filter(isAttachmentRead), openTable).toEqual([]); + expect(RelatedCountStore.get('sys_attachment', `attachments:${OBJECT}`, REC), openTable).toBe(FEED_ROWS.sys_attachment.length); expect(screen.getByText('signed-contract.pdf')).toBeTruthy(); }); @@ -658,21 +695,26 @@ describe('the timeline, comments, attachments and user list wait for their panel // panel, the first panel of the right column โ€” on screen at open in every // tier, so the feed is read at open, like an authored page opened on its // Activity tab. A page with tabs still waits for its tab (above). - it('the synthesized page reads the feed and the user list at open: its ๅŠจๆ€ panel is on screen', async () => { + // objectstack#28470 โ€” asked with the page, not beside it; the mention list + // waits for the composer's focus. + it('the synthesized page asks the feed with its page at open: its ๅŠจๆ€ panel is on screen', async () => { const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver }); const table = waveTable(held); expect(screen.queryByRole('tab', { name: /^Timeline/ }), table).toBeNull(); expect(document.querySelector('[data-record-timeline-panel]'), table).not.toBeNull(); + expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 200 }); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { - expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); + expect(held.filter(isRead), table).toEqual([]); } + expect(screen.getByText('Ping the buyer on Friday')).toBeTruthy(); }); - it('CONTROL: the synthesized page opened on its ๅŠจๆ€ tab (?related=timeline) reads the feed and the user list at open', async () => { + it('CONTROL: the synthesized page opened on its ๅŠจๆ€ tab (?related=timeline) asks the feed with its page at open', async () => { const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver, search: '?related=timeline' }); const table = waveTable(held); + expect(pageStreams(held), table).toMatchObject({ discussion: true, activity: 200 }); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { - expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); + expect(held.filter(isRead), table).toEqual([]); } }); }); @@ -757,7 +799,9 @@ describe('the ๅŠจๆ€ stream reads the audit log when it is shown (objectui#13010 it('an assigned page of a trackHistory object never reads the audit log', async () => { const { held } = await openHistory({ page: ACTIVITY_PAGE, search: '?tab=activity' }); const table = waveTable(held); - expect(held.filter(isActivityRead), table).toHaveLength(1); + // Its activity comes with the page (objectstack#28470); the audit log is never read. + expect(pageStreams(held).activity, table).toBe(200); + expect(held.filter(isActivityRead), table).toEqual([]); expect(held.filter(isAuditRead), table).toEqual([]); expect(held.filter(isAuditUserLookup), table).toEqual([]); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx index 60fc3541855..51c8da8cbbe 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx @@ -23,14 +23,15 @@ * asks nothing for the record it opens. * * objectstack#27524: the ๅŠจๆ€ panel leads the page's right column in every - * tier, so the stream is on screen at open and reads its own sources beside - * the page request, in the same wave: the record's comment thread, its - * activity and, once per view, the mention list. None of them is a part of the page the payload answers; - * they are pinned on their own below, not counted as page requests. + * tier, so the stream is on screen at open. objectstack#28470: its sources โ€” + * the record's comment thread and its activity โ€” are parts of the payload, and + * the mention list waits for the composer's focus, so the open reads none of + * them on its own; pinned below as an empty list of stream reads + * (`RecordDetailView.recordOpenOneRequest-28470.test.tsx` pins the rest). * * And once the app's metadata bundle is in (`loadAppBundle`, on app entry), the * object definitions the blocks read come from it: a record open in a warm - * session asks the page and no metadata at all (its ๅŠจๆ€ stream's own reads aside), and a cold one adds at most + * session asks the page and no metadata at all, and a cold one adds at most * the one bundle request. */ import * as React from 'react'; @@ -176,7 +177,7 @@ async function runWaves(held: Held[], state: { wave: number }) { const isMeta = (h: Held) => h.path.startsWith('/api/v1/meta/'); const isPage = (h: Held) => h.method === 'POST' && /\/api\/v1\/data\/[^/?]+\/[^/?]+\/page$/.test(h.path); -/** The ๅŠจๆ€ stream's own reads (objectstack#27524): its sources, not the page's parts. */ +/** The ๅŠจๆ€ stream's own reads (objectstack#27524), which the payload now answers (objectstack#28470). */ const STREAM_SOURCES = ['sys_comment', 'sys_activity', 'sys_user']; const isStreamRead = (h: Held) => h.method === 'GET' && STREAM_SOURCES.some((o) => h.path.startsWith(`/api/v1/data/${o}?`)); /** A stream read as ` `, or `` for the mention list, which names no record. */ @@ -255,9 +256,10 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { // Every badge's total is the payload's. for (const c of CHILDREN) expect(RelatedCountStore.get(c, 'contact', 'c1')).toBe(TOTAL[c]); - // The ๅŠจๆ€ panel's own reads go out beside the page request, in its wave. - expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']); - expect(held.filter(isStreamRead).every((h) => h.wave === 1)).toBe(true); + // The ๅŠจๆ€ panel's sources are the payload's parts (objectstack#28470): + // the open reads none of them on its own, nor the mention list. + expect(page.body).toMatchObject({ discussion: true, activity: { top: 200 } }); + expect(streamReads(held)).toEqual([]); }); it('a warm open of another record sends one page request too', async () => { @@ -271,7 +273,7 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { await runWaves(held, state); expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c2/page`]); expect(held.filter(isMeta)).toEqual([]); - expect(streamReads(held)).toEqual(['sys_activity c2', 'sys_comment c2', 'sys_user']); + expect(streamReads(held)).toEqual([]); }); it("asks the record a drawer step shows next ahead, so stepping to it sends nothing", async () => { @@ -292,8 +294,8 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { // The step's own page is the prefetched answer; only the next one is asked. expect(held.filter(isPage).map((h) => h.path)).toEqual([`/api/v1/data/${OBJECT}/c3/page`]); expect(dataRequests(held).filter((h) => !isPage(h))).toEqual([]); - // What the step does read is its ๅŠจๆ€ stream, the payload's to answer no part of. - expect(streamReads(held)).toEqual(['sys_activity c2', 'sys_comment c2', 'sys_user']); + // Nor its ๅŠจๆ€ stream: the prefetched payload carries it (objectstack#28470). + expect(streamReads(held)).toEqual([]); }); // Merge resolution with objectstack#27056 (#27636): in a drawer, that card // warms the next record's related counts; the payload's prefetch carries @@ -339,8 +341,8 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { `/api/v1/data/${OBJECT}/c2/page`, ]); expect(dataRequests(held).filter((h) => !isPage(h))).toEqual([]); - // The swapped-in record's ๅŠจๆ€ stream; the mention list is the view's, read once. - expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1']); + // The swapped-in record's ๅŠจๆ€ stream comes with its prefetched page (objectstack#28470). + expect(streamReads(held)).toEqual([]); }); it('a cold session, entering the app as the record opens, adds one bundle request and no by-name metadata read', async () => { @@ -350,8 +352,9 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { await runWaves(held, state); expect(held.filter(isMeta).map((h) => `${h.method} ${h.path}`)).toEqual([`GET ${BUNDLE}`]); expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); - // Both go out in the first wave, beside the ๅŠจๆ€ stream's own reads: nothing waits on the other. - expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']); + // Both go out in the first wave: nothing waits on the other, and the ๅŠจๆ€ + // stream reads nothing of its own (objectstack#28470). + expect(streamReads(held)).toEqual([]); expect(held.every((h) => h.wave === 1)).toBe(true); }); @@ -368,8 +371,8 @@ describe('objectstack#27627 โ€” one request opens a record page', () => { await runWaves(held, state); expect(held.filter(isMeta)).toEqual([]); expect(dataRequests(held).map((h) => `${h.method} ${h.path}`)).toEqual([`POST /api/v1/data/${OBJECT}/c1/page`]); - // What else goes out is the ๅŠจๆ€ stream's own data reads (objectstack#27524), no metadata. - expect(streamReads(held)).toEqual(['sys_activity c1', 'sys_comment c1', 'sys_user']); + // Nothing else goes out: the ๅŠจๆ€ stream is the payload's (objectstack#28470). + expect(streamReads(held)).toEqual([]); }); 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 () => { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 7e0804502e9..fc3aca22897 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -37,7 +37,15 @@ import { isRecordPagePushed, useRecordPageScrollMemory } from './recordPagePush. import { phoneRecordHeader } from './recordPhoneHeader.js'; import { usePhoneViewport } from '../hooks/usePhoneViewport.js'; import { PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, RecordPaneBarToggles, useRecordPane, useRecordPaneBar, useRecordPageReturn } from './recordDrawerStack.js'; -import { recordPageRelatedLists, recordPageRequestOf, seedRecordPageReaders } from './recordPagePayload.js'; +import { + RECORD_PAGE_ACTIVITY_ROWS, + RECORD_PAGE_ATTACHMENT_ROWS, + recordPageActivity, + recordPageComments, + recordPageRelatedLists, + recordPageRequestOf, + seedRecordPageReaders, +} from './recordPagePayload.js'; import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js'; import { RECORD_DETAIL_TAB_PARAM, RECORD_DRAWER_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_RELATED_ENLARGE_PUSHED_STATE as RELATED_ENLARGE_PUSHED, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; import { resolveActionParams } from '@object-ui/fields'; @@ -261,6 +269,9 @@ export { isSecondaryField } from './recordDetailSections.js'; */ const EMPTY_FEED: FeedItem[] = []; +/** One `@`-mention picker entry: a `sys_user` row of the directory read, by id and display label. */ +type MentionSuggestion = { id: string; label: string; avatarUrl?: string }; + /** * objectui#13010 โ€” the blocks that draw what this view's feed reads fetch. All * three read the DiscussionContext this view provides: its merged @@ -820,9 +831,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // rows (objectui#11195), so a refusal on record A never reaches record B's // panel. const [feedRefusalByRecord, setFeedRefusalByRecord] = useState>({}); - const [mentionSuggestions, setMentionSuggestions] = useState< - Array<{ id: string; label: string; avatarUrl?: string }> - >([]); + const [mentionSuggestions, setMentionSuggestions] = useState([]); // Screen-flow runtime: a paused `screen`-node flow launched from a record action. const [screenFlow, setScreenFlow] = useState(null); // A record-action flow launch that ended `refused` without pausing (objectui#9973). @@ -1082,6 +1091,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri recordKey: string; generation: number; payload: Promise; + /** + * objectstack#28470 โ€” the feed's record (`object:id`, the thread id) and + * which of its reads the payload answers: the feed effect takes those + * from it and reads only the others. + */ + feedKey: string; + discussion: boolean; + activity: boolean; } | null>(null); // objectstack#26589 โ€” the record this viewer just decided an approval on, // as `objectName` + `recordId`. A pending approver with no standing access @@ -2218,6 +2235,24 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // approver. So a request's page asks each part on its own through the // adapter, whose `findOne` the console routes to the approvals service // (`routeApprovalRecordReads`); the page never widens what the data API reads. + // + // objectstack#28470 โ€” and the record's own streams the page draws when it + // opens: the discussion and the 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 โ€” the same reading `feedConsumerOnScreen` makes + // below, taken with the page's `?tab=`), and the attachments when the page + // draws them. They ride the payload the plan asks and never ask one (see + // `recordPageStreams`), so a later tab switch asks nothing again. A related + // list names the lookups of its rows to resolve, as `RelatedList`'s own read + // expands them (every declared reference of the child but the parent's, + // gated by field-level security), so its rows carry the names the list draws. + const feedOnOpen = useMemo(() => { + if (!effectivePage) return false; + const page = objectDef?.enable?.feeds !== false ? effectivePage : stripDiscussionNodes(effectivePage); + if (page == null) return false; + return (pageColumnsEligible && buildDefaultPageColumns(page) !== null) + || pageShowsNodeOnOpen(page, FEED_CONSUMER_TYPES, activeTabParam); + }, [effectivePage, objectDef, pageColumnsEligible, activeTabParam]); const recordPagePlan = useMemo(() => { if (!hasPage || !objectName || !pureRecordId || typeof dataSource?.getRecordPage !== 'function') return ''; if (objectName === SYS_APPROVAL_REQUEST_OBJECT) return ''; @@ -2227,14 +2262,44 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // Claim asks a transfer verdict only of a record sitting in a queue; the // payload asks it of every record of an object that has the column. if (objectDef?.fields && Object.prototype.hasOwnProperty.call(objectDef.fields, 'queue')) explain.push('transfer'); + const relatedExpand = (child: string, field: string): string[] => { + const childDef = objects.find((o: any) => o?.name === child) as { fields?: Record } | undefined; + const expandable = buildExpandFields(childDef?.fields as any).filter((f) => f !== field); + return perms?.isLoaded ? expandable.filter((f) => perms.checkField(child, f, 'read')) : expandable; + }; return JSON.stringify({ expand: JSON.parse(pageRecordExpand) as string[], - related: recordPageRelatedLists(effectivePage, assignedPage ? undefined : childRelations), + related: recordPageRelatedLists(effectivePage, assignedPage ? undefined : childRelations, relatedExpand), explain, fieldAccess: typeof dataSource.getRecordFieldAccess === 'function', approvals: true, }); - }, [hasPage, objectName, pureRecordId, dataSource, objectDef, editOffByObject, deleteOffByObject, pageRecordExpand, effectivePage, assignedPage, childRelations]); + }, [hasPage, objectName, pureRecordId, dataSource, objectDef, editOffByObject, deleteOffByObject, pageRecordExpand, effectivePage, assignedPage, childRelations, objects, perms]); + // The stream parts ride whichever payload the plan above asks; they never ask + // one of their own. So they are read off a ref when the payload goes out, not + // keyed into the plan: an assigned page landing after a synthesized one (which + // draws the ๅŠจๆ€ panel the assigned one may not) changes them and asks nothing + // again (objectui#11699). The feed effect reads which streams the payload + // carries off `recordPageRead`, and asks the rest itself. + const recordPageStreams = { + ...(feedOnOpen && objectDef?.enable?.feeds !== false ? { discussion: true } : {}), + ...(feedOnOpen && objectDef?.enable?.activities !== false ? { activity: RECORD_PAGE_ACTIVITY_ROWS } : {}), + ...(objectDef?.enable?.files === true || hasExplicitAttachments(effectivePage as any) + ? { attachments: RECORD_PAGE_ATTACHMENT_ROWS } + : {}), + }; + const recordPageStreamsRef = useRef(recordPageStreams); + recordPageStreamsRef.current = recordPageStreams; + // objectstack#28470 โ€” whether the page has asked its payload yet, for the + // feed effect: `false` only while a page that asks one has not. The effect + // reads which of its reads the payload answers off `recordPageRead` when it + // runs; a later plan change (an assigned page landing, a grant narrowing an + // expand) re-runs nothing, as no plan change re-read the feed before. + const feedPlanReady = + typeof dataSource?.getRecordPage !== 'function' + || objectName === SYS_APPROVAL_REQUEST_OBJECT + || !hasPage + || recordPagePlan !== ''; const recordPagePrincipal = perms.userId ?? null; const recordPageGeneration = recordInvalidationNonce + childInvalidationNonce; const recordPageGenerationRef = useRef(recordPageGeneration); @@ -2244,12 +2309,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri recordPageRead.current = null; return; } - const plan = JSON.parse(recordPagePlan); + const plan = { ...JSON.parse(recordPagePlan), ...recordPageStreamsRef.current }; const payload = dataSource.getRecordPage(objectName, pureRecordId, recordPageRequestOf(plan)); recordPageRead.current = { recordKey: JSON.stringify([objectName, pureRecordId, JSON.stringify(plan.expand)]), generation: recordPageGenerationRef.current, payload, + feedKey: `${objectName}:${pureRecordId}`, + discussion: plan.discussion === true, + activity: plan.activity !== undefined, }; seedRecordPageReaders({ payload, objectName, recordId: pureRecordId, principal: recordPagePrincipal, ...plan }); }, [recordPagePlan, objectName, pureRecordId, dataSource, recordPagePrincipal]); @@ -2265,7 +2333,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (!opened || !recordPagePlan || !upcomingKey || !objectName) return; if (typeof dataSource?.prefetchRecordPage !== 'function') return; let cancelled = false; - const request = recordPageRequestOf(JSON.parse(recordPagePlan)); + // A neighbour's page asks the streams this one asked, so its prefetch does too. + const request = recordPageRequestOf({ ...JSON.parse(recordPagePlan), ...recordPageStreamsRef.current }); const prefetch = () => { if (cancelled) return; for (const upcomingId of upcomingKey.split('\n')) dataSource.prefetchRecordPage!(objectName, upcomingId, request); @@ -2675,30 +2744,33 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // them as @-mention suggestions to the DiscussionContext. Capped at 50 to // keep the dropdown tight; hosts can swap in a paginated/server-search // implementation later by mounting their own DiscussionContextProvider. - // Only the comment composer of a feed block reads the list, so it waits for - // one to be on screen (objectui#13010, `feedWanted` above). - useEffect(() => { - if (!dataSource || !feedWanted) return; - let cancelled = false; - + // Only a comment write reads the list, so it waits until one wants it + // (objectstack#28470), never the record's open: the composer's first focus + // (`onMentionSuggestionsWanted` on the DiscussionContext) draws the picker, + // and a write whose text holds an `@` before then (a reply, an edit) reads it + // to resolve its mentions (`mentionRosterFor`). One read per data source and + // user, shared by both: the directory is not the record's. + const mentionUserKey = JSON.stringify([user?.id ?? null, user?.name ?? null, user?.email ?? null]); + const mentionRosterRef = useRef<{ dataSource: unknown; userKey: string; read: Promise } | null>(null); + const readMentionRoster = useCallback((): Promise => { + const held = mentionRosterRef.current; + if (held && held.dataSource === dataSource && held.userKey === mentionUserKey) return held.read; // Always seed with the current user so the @-mention dropdown shows at // least one entry even when the backend has no sys_user directory. // Hosts wanting a richer roster should provide it via dataSource. - const selfSuggestion = user + const selfSuggestion: MentionSuggestion[] = user ? [{ id: String(user.id), label: user.name || user.email || String(user.id), avatarUrl: (user as any).image || undefined, }] : []; - - (async () => { + const read = (async () => { try { const res = await dataSource.find('sys_user', { $top: 50, $select: ['id', 'name', 'email', 'image'], } as any); - if (cancelled) return; const rows: any[] = Array.isArray(res) ? res : res?.data || []; const fetched = rows .map((u) => ({ @@ -2713,15 +2785,33 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ...selfSuggestion, ...fetched.filter((s) => !seen.has(s.id)), ]; - setMentionSuggestions(merged.length > 0 ? merged : selfSuggestion); + return merged.length > 0 ? merged : selfSuggestion; } catch { - if (cancelled) return; // Fall back to just the current user so mentions still work. - setMentionSuggestions(selfSuggestion); + return selfSuggestion; } })(); + mentionRosterRef.current = { dataSource, userKey: mentionUserKey, read }; + return read; + // `user` is read through `mentionUserKey`, its id, name and email. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSource, mentionUserKey]); + const [mentionsWanted, setMentionsWanted] = useState(false); + const wantMentionSuggestions = useCallback(() => setMentionsWanted(true), []); + useEffect(() => { + if (!dataSource || !mentionsWanted) return; + let cancelled = false; + void readMentionRoster().then((roster) => { + if (!cancelled) setMentionSuggestions(roster); + }); return () => { cancelled = true; }; - }, [dataSource, user?.id, user?.name, user?.email, feedWanted]); + }, [dataSource, mentionsWanted, readMentionRoster]); + /** The roster a write resolves `text`'s mentions through: read now if the text names someone before the picker did. */ + const mentionRosterFor = useCallback(async (text: string): Promise => { + if (mentionSuggestions.length > 0 || !dataSource || !text.includes('@')) return mentionSuggestions; + setMentionsWanted(true); + return readMentionRoster(); + }, [mentionSuggestions, dataSource, readMentionRoster]); // Memoize so the object identity is stable across renders โ€” otherwise // any effect that depends on it (e.g. the feed loader below) would @@ -2833,8 +2923,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // The comment read needs to know where reactions are stored; until the // registry says, nothing is read and the panel stays in its loading state. if (feedsEnabled && reactionStore === null) return; + // objectstack#28470 โ€” the page payload carries the feed's reads it was + // asked for: until the page has asked, nothing is read here. + if (!feedPlanReady) return; let cancelled = false; const threadId = `${objectName}:${pureRecordId}`; + // The payload this open asked, when it is this record's: the discussion + // and the activity it carries are taken from it, never read again. A read + // it was not asked for is this effect's own, as before. + const opened = recordPageRead.current; + const fromPage = opened && opened.feedKey === threadId ? opened : null; // The two reads below run in PARALLEL and are collected here so the // loading flag can close over BOTH of them โ€” see the `allSettled` at the // end of this effect. @@ -2891,7 +2989,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return toReactions(parsed); }; - if (feedsEnabled) inFlight.push(dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } }) + const commentsRead: Promise<{ data?: any[]; reactionRows?: Array> | null }> | null = !feedsEnabled + ? null + : fromPage?.discussion + ? recordPageComments(fromPage.payload) + : dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } }); + if (commentsRead) inFlight.push(commentsRead .then(async (res: any) => { recordRefusal('comments', false); if (!res?.data?.length) return; @@ -2906,7 +3009,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (reactionStore === 'records') { const commentIds: string[] = res.data.map((c: { id: unknown }) => String(c.id)); try { - records = groupReactionRows(await readCommentReactionRows(dataSource, commentIds), currentUser.id); + // From the payload, the reactions of these very comments; a + // refused reaction read there is `null`, as a failed one here. + const rows = fromPage?.discussion + ? res.reactionRows + : await readCommentReactionRows(dataSource, commentIds); + records = rows ? groupReactionRows(rows, currentUser.id) : null; } catch { records = null; } @@ -3000,16 +3108,23 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // activity to sees the panel say so, not "no activity". The verdict is // `isRefusedFeedRead`, the one the `record:activity` block's self-fetch // uses, so the two surfaces cannot disagree about what a refusal is. - if (activitiesEnabled) inFlight.push(dataSource.find('sys_activity', { - $filter: { object_name: objectName, record_id: pureRecordId }, - $orderby: { timestamp: 'asc' }, - $top: 200, - // The actor's record in place of its id, so a row with no `actor_name` - // names its user instead of reading "System" (objectui#12067). The - // shared constructor reads it; the `record:activity` block's own read - // passes the same expand. - $expand: ['actor_id'], - }) + const activityRead: Promise | null = !activitiesEnabled + ? null + : fromPage?.activity + // objectstack#28470 โ€” the payload's activity: the newest + // RECORD_PAGE_ACTIVITY_ROWS rows, the actor expanded. + ? recordPageActivity(fromPage.payload) + : dataSource.find('sys_activity', { + $filter: { object_name: objectName, record_id: pureRecordId }, + $orderby: { timestamp: 'desc' }, + $top: RECORD_PAGE_ACTIVITY_ROWS, + // The actor's record in place of its id, so a row with no `actor_name` + // names its user instead of reading "System" (objectui#12067). The + // shared constructor reads it; the `record:activity` block's own read + // passes the same expand. + $expand: ['actor_id'], + }); + if (activityRead) inFlight.push(activityRead .then((res: any) => { recordRefusal('activity', false); if (!res?.data?.length) return; @@ -3050,7 +3165,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }); return () => { cancelled = true; }; - }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted]); + }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady]); /** * Note: comment-mention โ†’ notification fan-out lives on the server @@ -3142,7 +3257,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }; // Persist to backend (M10.10: snake_case fields per sys_comment schema) const threadId = `${objectName}:${pureRecordId}`; - const mentionIds = extractMentions(text, mentionSuggestions); + const roster = await mentionRosterFor(text); + const mentionIds = extractMentions(text, roster); try { await dataSource.create('sys_comment', { id: newItem.id, @@ -3153,7 +3269,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri body: text, mentions: JSON.stringify(mentionIds), created_at: newItem.createdAt, - }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, roster) }); } catch (err) { throw commentWriteFailed(err); } @@ -3163,7 +3279,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri [key]: [...(prev[key] ?? EMPTY_FEED), newItem], })); }, - [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld], + [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionRosterFor, commentWriteFailed, tellMentionsWithheld], ); const handleAddReply = useCallback( @@ -3182,7 +3298,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri authoredByViewer: isCommentAuthor(currentUser.id, currentUser.id), }; const threadId = `${objectName}:${pureRecordId}`; - const mentionIds = extractMentions(text, mentionSuggestions); + const roster = await mentionRosterFor(text); + const mentionIds = extractMentions(text, roster); try { await dataSource.create('sys_comment', { id: newItem.id, @@ -3194,7 +3311,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri mentions: JSON.stringify(mentionIds), created_at: newItem.createdAt, parent_id: parentId, - }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, roster) }); } catch (err) { throw commentWriteFailed(err); } @@ -3214,7 +3331,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }; }); }, - [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld], + [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionRosterFor, commentWriteFailed, tellMentionsWithheld], ); /** @@ -3258,11 +3375,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const row = (feedItemsByRecord[key] ?? EMPTY_FEED).find(item => item.id === itemId); if (!row || row.type !== 'comment') return; let stored: { is_edited?: unknown } | null | undefined; + const roster = await mentionRosterFor(text); try { stored = await dataSource.update(COMMENT_OBJECT, String(itemId), { body: text, - mentions: JSON.stringify(extractMentions(text, mentionSuggestions)), - }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); + mentions: JSON.stringify(extractMentions(text, roster)), + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, roster) }); } catch (err) { throw editFailed(err); } @@ -3274,7 +3392,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ), })); }, - [dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t, tellMentionsWithheld], + [dataSource, feedRecordKey, feedItemsByRecord, mentionRosterFor, t, tellMentionsWithheld], ); /** @@ -4609,6 +4727,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri : undefined } mentionSuggestions={mentionSuggestions} + onMentionSuggestionsWanted={wantMentionSuggestions} > {/* objectstack#27055 โ€” the field changes of the timeline stream, for the synthesized page's `record:activity`. */} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx index 3a4e8a4b41f..0acfd221ff7 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx @@ -13,6 +13,10 @@ * warning naming them, with no reason given. A write that withheld nobody says * nothing. * + * Since objectstack#28470 the roster is not read when the record opens: the + * composer reads it on its first focus, and a reply or an edit whose text holds + * an `@` before then reads it at the write, so both resolve their mentions. + * * Real subjects: `RecordDetailView` on an authored page composing * `record:discussion`, driven through its real composer, reply input and edit * form, under a real `I18nProvider` with the `en` pack read by key. The data @@ -183,16 +187,21 @@ async function mount(dataSource: ReturnType) { ); const view = within(rendered.container); await view.findByText(ADA_ROW.body); - // The picker's roster has been read: the suggestions the writes map through. - await waitFor(() => expect(dataSource.find).toHaveBeenCalledWith('sys_user', expect.anything())); await act(async () => {}); + // The open reads no roster (objectstack#28470). + expect(dataSource.find).not.toHaveBeenCalledWith('sys_user', expect.anything()); return view; } type View = Awaited>; -async function postComment(view: View, text: string) { +async function postComment(view: View, dataSource: ReturnType, text: string) { const box = await view.findByPlaceholderText(EN.detail.leaveCommentPlaceholder); + // The composer's first focus reads the picker's roster: the suggestions the + // write maps through. + fireEvent.focus(box); + await waitFor(() => expect(dataSource.find).toHaveBeenCalledWith('sys_user', expect.anything())); + await act(async () => {}); fireEvent.change(box, { target: { value: text } }); await act(async () => { fireEvent.click(view.getByTitle(EN.detail.submitComment)); @@ -219,7 +228,7 @@ describe('the comment author is told who a mention will not reach (objectstack#2 const dataSource = makeDataSource(['u-grace', 'u-linus']); const view = await mount(dataSource); - await postComment(view, 'Please check @Grace Hopper and @Linus before Friday'); + await postComment(view, dataSource, 'Please check @Grace Hopper and @Linus before Friday'); await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Grace Hopper', 'Linus')); @@ -236,7 +245,7 @@ describe('the comment author is told who a mention will not reach (objectstack#2 const dataSource = makeDataSource(['u-linus']); const view = await mount(dataSource); - await postComment(view, '@Grace Hopper and @Linus, see above'); + await postComment(view, dataSource, '@Grace Hopper and @Linus, see above'); await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Linus')); @@ -246,7 +255,7 @@ describe('the comment author is told who a mention will not reach (objectstack#2 const dataSource = makeDataSource([]); const view = await mount(dataSource); - await postComment(view, 'Thanks @Grace Hopper'); + await postComment(view, dataSource, 'Thanks @Grace Hopper'); expect( await view.findByText('Thanks @Grace Hopper', { selector: ':not(textarea):not(input)' }), @@ -267,6 +276,8 @@ describe('the comment author is told who a mention will not reach (objectstack#2 await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Grace Hopper')); + // No composer was focused: the write read the roster to resolve its mention. + expect(dataSource.find.mock.calls.filter(([o]: [string]) => o === 'sys_user')).toHaveLength(1); expect(dataSource.create).toHaveBeenCalledWith( COMMENT, expect.objectContaining({ parent_id: 'c-ada', mentions: JSON.stringify(['u-grace']) }), diff --git a/ui/packages/app-shell/src/views/recordPagePayload.ts b/ui/packages/app-shell/src/views/recordPagePayload.ts index f654be9659b..11c69aa9ad9 100644 --- a/ui/packages/app-shell/src/views/recordPagePayload.ts +++ b/ui/packages/app-shell/src/views/recordPagePayload.ts @@ -24,7 +24,18 @@ * the details block's `useRecordEditable`); * - the `transfer` decision โ†’ `seedQueueClaimDecision` (Claim); * - the field projection โ†’ `seedRecordFieldAccess` (the in-place edit blocks); - * - the approval rows and lock โ†’ `seedRecordApprovals`. + * - the approval rows and lock โ†’ `seedRecordApprovals`; + * - the attachments' count and newest rows โ†’ `RelatedCountStore.seed`, under + * the key the Attachments tab badge counts and the panel takes its rows + * from (objectstack#28470); + * - the discussion and the activity โ†’ the page's feed, which takes them + * from the payload (`recordPageComments` / `recordPageActivity`) instead + * of reading `sys_comment`, `sys_comment_reaction` and `sys_activity` + * (objectstack#28470). + * + * Each related list names the lookups of its rows to resolve (`expand`), as + * the list's own read does, so the rows carry the names the list draws and no + * name lookup follows the payload (objectstack#28470). * * Each part passed the permission, row-level and field-level filters of the * read it replaces, on the server. A part the server refused reaches its @@ -38,7 +49,7 @@ */ import { RelatedCountStore } from '@object-ui/components'; -import { toFilterNode } from '@object-ui/core'; +import { isApiAccessDeniedError, toFilterNode } from '@object-ui/core'; import { COMPACT_RELATED_ROWS, normalizeRelatedListSort, @@ -58,6 +69,26 @@ import { seedRecordApprovals, type ApprovalRequestLite } from '../hooks/useRecor /** The most related lists one payload names (the route's own cap). */ const MAX_RELATED = 50; +/** + * How many activity rows the page asks (objectstack#28470): the window the + * record feed always read (`$top: 200`). The ๅŠจๆ€ panel draws the newest few, + * but its count and the ๅŠจๆ€ tab are the whole merged stream the page holds, + * so the page asks the window the stream is drawn from, not the panel's peek. + */ +export const RECORD_PAGE_ACTIVITY_ROWS = 200; + +/** How many attachments the page asks: the Attachments panel's window (`$top: 100`). */ +export const RECORD_PAGE_ATTACHMENT_ROWS = 100; + +/** + * The related-count store key the attachments of `objectName`'s records are + * counted and held under: the synthetic relationship the Attachments tab + * badge probes (`containers.tsx`), which the panel takes its rows from. + */ +export function recordAttachmentsCountField(objectName: string): string { + return `attachments:${objectName}`; +} + /** One related list the page draws, by the related-count store's key. */ export interface RecordPageRelatedList { object: string; @@ -68,6 +99,8 @@ export interface RecordPageRelatedList { top: number; /** The order of those rows; absent for the default order. */ sort?: Array<{ field: string; order: 'asc' | 'desc' }>; + /** The lookups of the rows to resolve, as the list's own read expands them. */ + expand?: string[]; } /** A relation `deriveRelatedLists` derived, as the synthesized page lists it. */ @@ -91,9 +124,12 @@ const propsOf = (node: Record): Record => export function recordPageRelatedLists( page: unknown, derived?: ReadonlyArray, + expandOf?: (object: string, field: string) => readonly string[] | undefined, ): RecordPageRelatedList[] { const byKey = new Map(); - const add = (list: RecordPageRelatedList, rail: boolean) => { + const add = (bare: RecordPageRelatedList, rail: boolean) => { + const expand = expandOf?.(bare.object, bare.field); + const list = expand && expand.length > 0 ? { ...bare, expand: [...expand] } : bare; if (list.filter !== undefined) { if (CONTEXT_TOKEN.test(JSON.stringify(list.filter))) return; try { @@ -168,6 +204,12 @@ export function recordPageRequestOf(plan: { explain: RecordPageOperation[]; fieldAccess: boolean; approvals: boolean; + /** The record's discussion (objectstack#28470). */ + discussion?: boolean; + /** How many activity rows, when the page draws the feed's activity. */ + activity?: number; + /** How many attachments, when the page draws them. */ + attachments?: number; }): RecordPageRequest { return { ...(plan.expand.length > 0 ? { expand: plan.expand } : {}), @@ -177,10 +219,14 @@ export function recordPageRequestOf(plan: { ...(list.filter === undefined ? {} : { filter: toFilterNode(list.filter) }), top: list.top, ...(list.sort ? { sort: list.sort } : {}), + ...(list.expand && list.expand.length > 0 ? { expand: list.expand } : {}), })), ...(plan.explain.length > 0 ? { explain: plan.explain } : {}), ...(plan.fieldAccess ? { fieldAccess: true } : {}), ...(plan.approvals ? { approvals: true } : {}), + ...(plan.discussion ? { discussion: true } : {}), + ...(plan.activity !== undefined ? { activity: { top: plan.activity } } : {}), + ...(plan.attachments !== undefined ? { attachments: { top: plan.attachments } } : {}), }; } @@ -195,6 +241,70 @@ function refusal(error: RecordPagePartRefusal | undefined, fallback: string): Er const isRefused = (part: unknown): part is { error: RecordPagePartRefusal } => !!part && typeof part === 'object' && 'error' in (part as object); +/** + * A stream part's refusal as its read's `find` answers it: the adapter reads a + * bare 404 (a system table this deployment does not provision) as an empty + * answer, never an object the API withholds (objectui#4408), so the part does + * too โ€” `null`. Anything else is the refusal, thrown. + */ +function streamRefusal(error: RecordPagePartRefusal, fallback: string): Error | null { + const thrown = refusal(error, fallback); + if (isApiAccessDeniedError(thrown)) return thrown; + const notFound = error.status === 404 || error.code === 'OBJECT_NOT_FOUND' || error.code === 'RECORD_NOT_FOUND'; + return notFound ? null : thrown; +} + +/** The discussion as the page's feed reads it (objectstack#28470). */ +export interface RecordPageComments { + /** The thread's comments, oldest first โ€” the comment read's `data`. */ + data: Record[]; + /** + * The comments' reaction rows, oldest first; `null` when their read + * refused (the comments show without reactions, as a failed reaction read + * leaves them). + */ + reactionRows: Record[] | null; +} + +/** + * The payload's discussion as the comment read and its reaction read answer + * it: rejects with the comment read's refusal. A payload with no record (the + * record read's 404) holds no thread. + */ +export function recordPageComments(payload: Promise): Promise { + return payload.then((page) => { + const part = page?.discussion; + if (!page || !part) return { data: [], reactionRows: [] }; + if (isRefused(part)) { + const thrown = streamRefusal(part.error, 'Comments refused'); + if (thrown) throw thrown; + return { data: [], reactionRows: [] }; + } + const reactions = part.reactions; + let reactionRows: Record[] | null; + if (isRefused(reactions)) reactionRows = streamRefusal(reactions.error, 'Reactions refused') ? null : []; + else reactionRows = reactions.records ?? []; + return { data: part.comments ?? [], reactionRows }; + }); +} + +/** + * The payload's activity as the record feed's activity read answers it + * (`{ data }`): rejects with that read's refusal. + */ +export function recordPageActivity(payload: Promise): Promise<{ data: Record[] }> { + return payload.then((page) => { + const part = page?.activity; + if (!page || !part) return { data: [] }; + if (isRefused(part)) { + const thrown = streamRefusal(part.error, 'Activity refused'); + if (thrown) throw thrown; + return { data: [] }; + } + return { data: part.records ?? [] }; + }); +} + /** * Hand each part of the payload to the reader that asks it. Call it before any * reader's effect runs (a layout effect of the page), so each reader joins its @@ -209,6 +319,8 @@ export function seedRecordPageReaders(args: { explain: RecordPageOperation[]; fieldAccess: boolean; approvals: boolean; + /** How many attachments the payload asked, when it asked them. */ + attachments?: number; }): void { const { payload, objectName, recordId, principal } = args; // A seeded part nobody asks for (a reader already reading, or none mounted) @@ -254,6 +366,28 @@ export function seedRecordPageReaders(args: { return answer; }))); } + if (args.attachments !== undefined) { + // objectstack#28470 โ€” the Attachments tab badge's count and the panel's + // rows, under the badge's key: the badge's probe joins it, the panel + // takes it on its first read. + RelatedCountStore.seed( + 'sys_attachment', + recordAttachmentsCountField(objectName), + recordId, + undefined, + args.attachments, + payload.then((page) => { + const part = page?.attachments; + if (!page || !part) return { total: 0, rows: [] }; + if (isRefused(part)) { + const thrown = streamRefusal(part.error, 'Attachments refused'); + if (thrown) throw thrown; + return { total: 0, rows: [] }; + } + return { total: part.total ?? 0, rows: part.records ?? [] }; + }), + ); + } if (args.approvals) { seedRecordApprovals(objectName, recordId, quiet(payload.then((page) => { const answer = page?.approvals; diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index 23df968713f..16c3dbd2fe6 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -111,6 +111,8 @@ export interface RecordActivityTimelineProps { collapseWhenEmpty?: boolean; /** Optional list of mention suggestions for the rich comment input. */ mentionSuggestions?: Array<{ id: string; label: string; avatarUrl?: string }>; + /** Called on the comment input's focus, for the host to read {@link mentionSuggestions} (objectstack#28470). */ + onMentionSuggestionsWanted?: () => void; /** Optional uploader for comment attachments. When provided, the composer * exposes a drag-and-drop file panel. */ onUploadAttachments?: (files: FileList) => Promise; @@ -313,6 +315,7 @@ export const RecordActivityTimeline: React.FC = ({ onToggleSubscription, collapseWhenEmpty = false, mentionSuggestions, + onMentionSuggestionsWanted, onUploadAttachments, titleLabel, emptyLabel, @@ -765,6 +768,7 @@ export const RecordActivityTimeline: React.FC = ({ onChange={setCommentText} onSubmit={handleAddComment} mentionSuggestions={mentionSuggestions} + onMentionSuggestionsWanted={onMentionSuggestionsWanted} placeholder={t('detail.leaveCommentPlaceholder')} disabled={isSubmitting} canSubmitEmpty={pendingAttachments.length > 0} diff --git a/ui/packages/plugin-detail/src/RecordChatterPanel.tsx b/ui/packages/plugin-detail/src/RecordChatterPanel.tsx index a7f772d2d87..d4d9ffcb5a5 100644 --- a/ui/packages/plugin-detail/src/RecordChatterPanel.tsx +++ b/ui/packages/plugin-detail/src/RecordChatterPanel.tsx @@ -49,6 +49,8 @@ export interface RecordChatterPanelProps { /** Optional list of users (or any mentionable entities) the comment * input can suggest after the user types `@`. */ mentionSuggestions?: Array<{ id: string; label: string; avatarUrl?: string }>; + /** Called on the comment input's focus, for the host to read {@link mentionSuggestions} (objectstack#28470). */ + onMentionSuggestionsWanted?: () => void; /** Optional uploader. When provided the composer renders an attachment * panel; returned attachments are passed to `onAddComment`. */ onUploadAttachments?: (files: FileList) => Promise = ({ onFilterChange, collapseWhenEmpty = false, mentionSuggestions, + onMentionSuggestionsWanted, onUploadAttachments, activityDenied, commentsDenied, @@ -204,6 +207,7 @@ export const RecordChatterPanel: React.FC = ({ onFilterChange={onFilterChange} collapseWhenEmpty={collapseWhenEmpty} mentionSuggestions={mentionSuggestions} + onMentionSuggestionsWanted={onMentionSuggestionsWanted} onUploadAttachments={onUploadAttachments} titleLabel={t('detail.discussion')} emptyLabel={t('detail.noCommentsYet')} @@ -267,6 +271,7 @@ export const RecordChatterPanel: React.FC = ({ onFilterChange={onFilterChange} collapseWhenEmpty={collapseWhenEmpty} mentionSuggestions={mentionSuggestions} + onMentionSuggestionsWanted={onMentionSuggestionsWanted} onUploadAttachments={onUploadAttachments} titleLabel={t('detail.discussion')} emptyLabel={t('detail.noCommentsYet')} diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 657e9a1920a..fb538c0beab 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -1774,12 +1774,20 @@ export const RelatedList: React.FC = ({ // is the FALLBACK half: the auto-fetch above now asks the server to expand // the same family, so this path runs for data a CALLER supplied, and for a // backend that does not honour `$expand`. + // + // objectstack#28470 โ€” never the parent FK (`referenceField`): `filterFK` + // drops it from every column list this component draws, so its name is one + // nothing renders, and every row holds the same id โ€” the record whose page + // this is. It was the one lookup a seeded or self-read row always left bare + // (the expansion above leaves it out for that reason), so each card paid a + // `?$top=1` read of the page's own record after its rows arrived. React.useEffect(() => { if (!dataSource?.find || !objectSchema?.fields || !relatedData.length) return; const fields = objectSchema.fields as Record; const tasks: Array<{ fieldName: string; target: string; ids: string[] }> = []; for (const [fieldName, def] of Object.entries(fields)) { if (!isExpandableFieldType(def)) continue; + if (fieldName === referenceField) continue; // objectui#6837 half 2 โ€” maintainer 2026-08-31: protocol normalization // belongs on the SERVER, the front end just executes the protocol. // `reference` is the only target spelling `@objectstack/spec`'s @@ -1846,7 +1854,7 @@ export const RelatedList: React.FC = ({ // Intentionally exclude `lookupLabels` from deps: we add to the cache and // would otherwise loop. We dedupe via the `missing` check. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSource, objectSchema, relatedData]); + }, [dataSource, objectSchema, relatedData, referenceField]); // Confirm-delete dialog state. Replaces window.confirm() so the related // list matches the rest of the app's Shadcn AlertDialog UX. diff --git a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx index b7c60fd39e7..18f8087ed84 100644 --- a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx +++ b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx @@ -45,6 +45,9 @@ export interface RichTextCommentInputProps { /** Focus the editor when it mounts: the ๅŠจๆ€ panel opens this composer * from its one-line stand-in, and the reader's press lands in it. */ autoFocus?: boolean; + /** Called when the editor takes focus: the host reads the mention + * suggestions then, not when the record opens (objectstack#28470). */ + onMentionSuggestionsWanted?: () => void; } /** The look of a picked mention, in the preview and in the composer's chip row. */ @@ -110,6 +113,7 @@ export const RichTextCommentInput: React.FC = ({ extraSlot, canSubmitEmpty = false, autoFocus = false, + onMentionSuggestionsWanted, }) => { const { t } = useDetailTranslation(); const [isPreview, setIsPreview] = React.useState(false); @@ -358,6 +362,7 @@ export const RichTextCommentInput: React.FC = ({ value={value} onChange={handleTextChange} onKeyDown={handleKeyDown} + onFocus={onMentionSuggestionsWanted} disabled={disabled} /> diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index 644fae7c3a5..30c007df613 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -370,6 +370,7 @@ export const RecordActivityRenderer: React.FC = ({ onEditComment={discussion?.onEditComment} onDeleteComment={discussion?.onDeleteComment} mentionSuggestions={mentionsEnabled ? (discussion?.mentionSuggestions as any) : undefined} + onMentionSuggestionsWanted={mentionsEnabled ? discussion?.onMentionSuggestionsWanted : undefined} onUploadAttachments={discussion?.onUploadAttachments as any} activityDenied={activityDenied} commentsDenied={commentsDenied} diff --git a/ui/packages/plugin-detail/src/renderers/record-chatter.tsx b/ui/packages/plugin-detail/src/renderers/record-chatter.tsx index 2c8a8ba7daf..17650ac196f 100644 --- a/ui/packages/plugin-detail/src/renderers/record-chatter.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-chatter.tsx @@ -295,6 +295,7 @@ export const RecordChatterRenderer: React.FC = ({ onEditComment={discussion?.onEditComment} onDeleteComment={discussion?.onDeleteComment} mentionSuggestions={mentionsEnabled ? (discussion?.mentionSuggestions as any) : undefined} + onMentionSuggestionsWanted={mentionsEnabled ? discussion?.onMentionSuggestionsWanted : undefined} onUploadAttachments={discussion?.onUploadAttachments as any} // objectui#11195: which of the host's two feed reads the server // REFUSED. The host that owns the fetch decides it, the same way it diff --git a/ui/packages/react/src/context/DiscussionContext.tsx b/ui/packages/react/src/context/DiscussionContext.tsx index d3dd9131684..f118265bf91 100644 --- a/ui/packages/react/src/context/DiscussionContext.tsx +++ b/ui/packages/react/src/context/DiscussionContext.tsx @@ -64,6 +64,13 @@ export interface DiscussionContextValue { * input can suggest after the user types `@`. When omitted the input * falls back to free-text @-mentions without autocompletion. */ mentionSuggestions?: DiscussionMentionSuggestion[]; + /** + * Ask the host for {@link mentionSuggestions} (objectstack#28470). A comment + * composer calls it on its first focus, so the host reads the list when a + * reader is about to write, never when the record opens. Idempotent: the + * host reads once, and a host that already holds the list ignores it. + */ + onMentionSuggestionsWanted?: () => void; /** Optional uploader. When provided, the comment composer renders a * drag-and-drop attachment panel; returned attachments are passed * alongside the comment text to `onAddComment`. */ @@ -102,6 +109,7 @@ export const DiscussionContextProvider: React.FC value.onEditComment, value.onDeleteComment, value.mentionSuggestions, + value.onMentionSuggestionsWanted, value.onUploadAttachments, value.loading, value.error, diff --git a/ui/packages/types/src/data.ts b/ui/packages/types/src/data.ts index 492c58d294e..7cc610e93b8 100644 --- a/ui/packages/types/src/data.ts +++ b/ui/packages/types/src/data.ts @@ -407,6 +407,21 @@ export interface RecordPagePayload { fieldAccess?: RecordFieldAccess | { error: RecordPagePartRefusal }; explain?: Partial>; approvals?: { requests: Record[]; lock: { locked: boolean } | null } | { error: RecordPagePartRefusal }; + /** + * The record's comment thread and those comments' reactions + * (objectstack#28470), or the comment read's refusal. The reactions carry + * their own refusal, which leaves the comments answered. + */ + discussion?: + | { + comments: Record[]; + reactions: { records: Record[] } | { error: RecordPagePartRefusal }; + } + | { error: RecordPagePartRefusal }; + /** The record's newest activity rows and their count, or their read's refusal (objectstack#28470). */ + activity?: { total: number; records: Record[] } | { error: RecordPagePartRefusal }; + /** The record's newest attachments and their count, or their read's refusal (objectstack#28470). */ + attachments?: { total: number; records: Record[] } | { error: RecordPagePartRefusal }; } /** From 298f965733ef66154a904532028eecbc0a49e959 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:06:11 +0000 Subject: [PATCH 3/7] fix(rest): spell the record page parts' expand as the QueryAST relation map The related list part and the activity part pass `expand` as the relation map the list read's normalizer lowers `$expand=a,b` to, so the rest package type-checks and the rows expand as that read's rows do. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- packages/rest/src/record-page-routes.test.ts | 9 +++++---- packages/rest/src/record-page-routes.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/record-page-routes.test.ts b/packages/rest/src/record-page-routes.test.ts index ffa771b18f7..b1629d4d5ba 100644 --- a/packages/rest/src/record-page-routes.test.ts +++ b/packages/rest/src/record-page-routes.test.ts @@ -380,11 +380,12 @@ describe('[#28470] the record\'s streams and the related rows\' names ride in th const data = RecordPageResponseSchema.parse(answer.body.data); const reads = findData.mock.calls.map(([req]) => req); - // The related list's read carries its own `$expand`, and its rows the - // resolved references. + // The related list's read carries its own `$expand` (as the relation + // map the list read's normalizer lowers `$expand=owner` to), and its + // rows the resolved references. expect(reads).toContainEqual({ object: 'note', - query: { object: 'note', where: { account: 'a1' }, limit: 3, count: true, expand: ['owner'] }, + query: { object: 'note', where: { account: 'a1' }, limit: 3, count: true, expand: { owner: { object: 'owner' } } }, context: CTX, }); expect(data.related[0]).toEqual({ object: 'note', field: 'account', total: 7, records: [{ id: 'note-1', owner: { id: 'user-1', name: 'Una' } }] }); @@ -415,7 +416,7 @@ describe('[#28470] the record\'s streams and the related rows\' names ride in th orderBy: [{ field: 'timestamp', order: 'desc' }], limit: 200, count: true, - expand: ['actor_id'], + expand: { actor_id: { object: 'actor_id' } }, }, context: CTX, }); diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts index 10965fbb918..106939d9eeb 100644 --- a/packages/rest/src/record-page-routes.ts +++ b/packages/rest/src/record-page-routes.ts @@ -57,6 +57,15 @@ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; const ACTIVITY_OBJECT = 'sys_activity'; const ATTACHMENT_OBJECT = 'sys_attachment'; +/** + * A `$expand` list spelled as the relation map the QueryAST declares, as the + * list read's normalizer lowers `$expand=a,b` (`{ a: { object: 'a' } }`): the + * same map, so a part's rows expand exactly as that read's rows do (#28470). + */ +function relationMap(fields: readonly string[]): Record { + return Object.fromEntries(fields.map((rel): [string, { object: string }] => [rel, { object: rel }])); +} + /** * The `RestServer` members {@link registerRecordPageEndpoints} reads โ€” every * one of them, and no other (`data-action-routes.ts`' header carries why each @@ -229,7 +238,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: ...(entry.sort && entry.sort.length > 0 ? { orderBy: entry.sort } : {}), // [#28470] the list read's own `$expand`, so // its rows carry the names the list draws. - ...(entry.expand && entry.expand.length > 0 ? { expand: entry.expand } : {}), + ...(entry.expand && entry.expand.length > 0 ? { expand: relationMap(entry.expand) } : {}), }, ...scope, } satisfies ServerScopedDataRequest); @@ -298,7 +307,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: orderBy: [{ field: 'timestamp', order: 'desc' }], limit: top, count: true, - expand: ['actor_id'], + expand: relationMap(['actor_id']), }); return 'error' in answer ? answer : { total: answer.total, records: answer.records }; }; From 579bcda1f7aa90689dc512e913d4792b343cadb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:24:29 +0000 Subject: [PATCH 4/7] chore(changeset): the record page streams changeset declares Metadata-protocol added, spec minor The amended claim reads the 17 new api/RecordPage* authorable keys as a metadata-protocol addition, so @objectstack/spec moves to minor. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- .changeset/28470-record-page-streams.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/28470-record-page-streams.md b/.changeset/28470-record-page-streams.md index 2de77b45294..6c5f8528cd7 100644 --- a/.changeset/28470-record-page-streams.md +++ b/.changeset/28470-record-page-streams.md @@ -1,12 +1,12 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor '@objectstack/rest': patch --- feat(rest): the record page payload carries the record's discussion, activity and attachments, and the related rows' names (#28470) Clause-โ‘ก: yes (widening) -Metadata-protocol: none +Metadata-protocol: added `POST /api/v1/data/{object}/{id}/page` answers the reads a record page still made on its own after the payload arrived. Each new part is the read it replaces, asked as the same caller behind that object's own exposure gate, and each is refused on its own: From 2369096b48db7758ffb901349a9b295ad5976162 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:50:56 +0000 Subject: [PATCH 5/7] fix(rest,console): the record page stream read builds one census-counted query slot The stream reader's parameter no longer spells `query:`, so the REST query AST census counts one inline-literal slot for it; the record page module's floor moves 1 -> 2 and the registrar sum 5 -> 6. The approval request page's served-page test pins that the timeline asks no label read of its parent `request_id`, which no column draws, and that its actor lookup is still labelled. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- packages/rest/src/record-page-routes.ts | 4 +-- .../rest-server-canonical-query-ast.test.ts | 10 +++++-- ...ovalRequestsDataSource.servedPage.test.tsx | 29 ++++++++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts index 106939d9eeb..ab438860680 100644 --- a/packages/rest/src/record-page-routes.ts +++ b/packages/rest/src/record-page-routes.ts @@ -255,7 +255,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: // the read's own, carried as the part's answer. const readStream = async ( name: string, - query: Record, + read: Record, ): Promise<{ records: any[]; total: number } | { error: PartRefusal }> => { const item = streamItems.get(name); const denial = item ? apiAccessDenialFromEnable(item.enable, name, 'list') : null; @@ -266,7 +266,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: try { const found: any = await p.findData({ object: name, - query: { object: name, ...query }, + query: { object: name, ...read }, ...scope, } satisfies ServerScopedDataRequest); const records = Array.isArray(found?.records) ? found.records : []; diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 5e20fd0ab57..94452450114 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -206,6 +206,11 @@ interface CensusEntry { * [#27627] 5, unchanged: the app metadata bundle (`meta-app-bundle-routes.ts`) * reads metadata only and builds no `query:` slot, so the new module's floor * is 0. + * + * [#28470] 6 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 2 + 0: + * the record page's stream reads (the discussion, its reactions, the activity + * and the attachments) go through one server-built slot (`readStream`), so the + * module's floor is 2. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -226,7 +231,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#26433] The list's length is no longer pinned: the list is the authority. // [#27627] The sum 4 โ†’ 5: the record page's related list read. // [#27627] The sum unchanged by the app metadata bundle (no slot). + // [#28470] The sum 5 โ†’ 6: the record page's stream reads (`readStream`). expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); - expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(5); + expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(6); }); it('dropping comment lines leaves the code that is being censused', () => { diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx index ee109cb4402..eed2d286611 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx @@ -19,9 +19,12 @@ * `decision_progress`), through the provider, never through the adapter; * - the Timeline tab's list AND its count from * `GET /approvals/requests/:id/actions`; - * - the timeline's label read of its `request_id` column from the adapter, - * never refused (the build round's carried pin: under its route the same - * read was refused by the routed source's list). + * - no label read of the timeline's `request_id` (objectstack#28470): it is + * the parent FK, which the related list draws in no column, so the list no + * longer asks the adapter for the request's own name. Every other lookup + * label read the page makes is still never refused (the build round's + * carried pin: under its route a label read was refused by the routed + * source's list). */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; @@ -304,7 +307,7 @@ describe("the served request page, opened through the console's adapter layer (o expect(host.find.mock.calls.some(([resource]) => resource === APPROVAL_ACTION_OBJECT)).toBe(false); }); - it("the timeline's label read of its request_id column reaches the adapter, and no read the page makes is refused", async () => { + it("the timeline asks no label read of its parent request_id, and no read the page makes is refused", async () => { const warn = vi.spyOn(console, 'warn'); try { openServedPage(); @@ -312,18 +315,24 @@ describe("the served request page, opened through the console's adapter layer (o fireEvent.mouseDown(tab); fireEvent.click(tab); await screen.findByText('Budget checked', undefined, { timeout: 8000 }); + // The rows' other lookup (`actor_id`) is labelled: the fallback ran. await waitFor( () => expect( - host.find.mock.calls.some( - ([resource, params]) => - resource === APPROVAL_REQUEST_OBJECT && - JSON.stringify((params as { $filter?: unknown })?.$filter ?? null).includes(REQ), - ), - 'the label read asked the adapter for the parent request', + host.find.mock.calls.some(([resource]) => resource === 'sys_user'), + 'the label read of the rows\' actor_id reached the adapter', ).toBe(true), { timeout: 8000 }, ); + // objectstack#28470 โ€” never the parent FK: no column draws it. + expect( + host.find.mock.calls.some( + ([resource, params]) => + resource === APPROVAL_REQUEST_OBJECT && + JSON.stringify((params as { $filter?: unknown })?.$filter ?? null).includes(REQ), + ), + 'no label read asked the adapter for the parent request', + ).toBe(false); const lookupFailures = warn.mock.calls.filter(([message]) => String(message).includes('Failed to resolve lookups')); expect(lookupFailures, 'no lookup-label read was refused').toEqual([]); } finally { From f2e04ba8ef60e9c213b0bc9214832c1d746fddc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:23:18 +0000 Subject: [PATCH 6/7] docs(plugin-detail): the console takes a record's discussion from the record page payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Console Integration" section still showed the console reading a record's comments with `dataSource.find('sys_comment', โ€ฆ)`, with field names already stale. A record open now asks the discussion and the activity in its one `POST /data/{object}/{id}/page` request; the section says so, and when the console still reads `sys_comment` on its own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- .../docs/frontend/plugins/plugin-detail.mdx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/content/docs/frontend/plugins/plugin-detail.mdx b/content/docs/frontend/plugins/plugin-detail.mdx index 048fb951d6b..69007726c84 100644 --- a/content/docs/frontend/plugins/plugin-detail.mdx +++ b/content/docs/frontend/plugins/plugin-detail.mdx @@ -100,7 +100,12 @@ declare const items: FeedItem[]; ### Console Integration -In the Console app, `RecordChatterPanel` is integrated with record detail views. Comments are fetched from the `sys_comment` object using a thread ID pattern of `{objectName}:{recordId}`: +In the Console app, `RecordChatterPanel` is integrated with record detail views. A record +page that draws its feed when it opens takes the discussion from the one request it makes +on open, `POST /api/v1/data/{object}/{id}/page` (the [record page payload](/docs/references/api/record-page)). +That request asks `discussion` (the record's comment thread and those comments' reactions) +and `activity` (its newest activity rows) beside the record, and the page reads neither +`sys_comment` nor `sys_activity` on its own: ```tsx import type { DataSource, FeedItem } from '@object-ui/types'; @@ -110,26 +115,33 @@ declare const objectName: string; declare const recordId: string; declare const dataSource: DataSource; -// Thread ID format -const threadId = `${objectName}:${recordId}`; - -// Fetch comments โ€” `find` resolves to a `QueryResult`, so the rows are `.data`. -const comments = await dataSource.find('sys_comment', { - $filter: { threadId }, - $orderby: 'createdAt asc', +// The record page's one request; the discussion and the activity are two of its parts. +const page = await dataSource.getRecordPage?.(objectName, recordId, { + discussion: true, + activity: { top: 200 }, }); -// Map to FeedItem[] -const feedItems: FeedItem[] = comments.data.map((c) => ({ - id: c._id, +// A part is its rows, or the refusal of the read it replaces. +const discussion = page?.discussion; +const comments = discussion && 'comments' in discussion ? discussion.comments : []; + +// Map to FeedItem[]: `sys_comment` columns are snake_case. +const feedItems: FeedItem[] = comments.map((c) => ({ + id: String(c.id), type: 'comment', - actor: c.author, + actor: c.author_name, body: c.body, - createdAt: c.createdAt, - reactions: c.reactions ?? [], + createdAt: c.created_at, })); ``` +A refused part is drawn as refused and is not asked again, and the other parts stay +answered: a refused reaction read leaves the comments shown without reactions. Only a feed +the open did not ask for is read on its own, from `sys_comment` by its `thread_id` +(`{objectName}:{recordId}`), oldest first: for example a feed on a tab the page did not +open on, or any feed on a data source without `getRecordPage`. The composer's mention list +(`sys_user`, 50 rows) is read when the composer is first focused, not when the record opens. + **Full-page detail view** renders the panel non-collapsible at the bottom: ```tsx From 3b6fec5285b7b84331947ae2750103972241ac43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:07:43 +0000 Subject: [PATCH 7/7] chore(changeset): the record page streams changeset grades @objectstack/rest minor The route gains four accepted body members and three answer members, an additive feature on a published package; the contract review asked the level to read `minor`, as the door's first changeset graded it. Both packages sit in one fixed group, so the released version is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSSzaZAuzM1JMwdqpC --- .changeset/28470-record-page-streams.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/28470-record-page-streams.md b/.changeset/28470-record-page-streams.md index 6c5f8528cd7..ae39ccd5e4d 100644 --- a/.changeset/28470-record-page-streams.md +++ b/.changeset/28470-record-page-streams.md @@ -1,6 +1,6 @@ --- '@objectstack/spec': minor -'@objectstack/rest': patch +'@objectstack/rest': minor --- feat(rest): the record page payload carries the record's discussion, activity and attachments, and the related rows' names (#28470)