diff --git a/.changeset/30343-timeline-one-action-one-entry.md b/.changeset/30343-timeline-one-action-one-entry.md new file mode 100644 index 00000000000..fcc121f7631 --- /dev/null +++ b/.changeset/30343-timeline-one-action-one-entry.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +--- + +fix(spec,rest): the record page's `timeline` count counts one create or delete written to both the activity and the ledger once, as the 动态 stream draws it (#30343) + +Clause-②: yes (narrowing) — `RecordPageTimelineCountSchema.total` falls for the same rows: with a field change counted, `created` (`deleted`) activity rows now step aside as often as the ledger holds `create` (`delete`) writes, beside the `updated` rows that already did; widening in the same line: `@objectstack/spec/api` exports one more constant, `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`. No exported member is removed or renamed. +Metadata-protocol: none — a read answer's count, not anything an author writes. + + + +- **Now.** An audit reader on a record with field history sees the 动态 badge count one action once: one create and one edit read 「动态 2」 above the two rows the stream draws. Before, the create counted twice (3). +- **The rule.** Every `updated` row steps aside, as before. A create (a delete) steps aside as often as both sources hold one: the fewer of the record's `created` (`deleted`) activity rows and its `create` (`delete`) ledger writes. A record created before its history was kept has no `create` write, so its `created` row stays in the count, as the stream draws it. +- **Unchanged.** A member without audit read counts the same activity rows as before. A comment adds one. The total stays an upper bound for the `showCompleted` / `types` and no-diff cases; it can fall one short where the stream does not pair a create's (delete's) two rows (different actors, or stamped more than the stream's pairing window apart). diff --git a/content/docs/references/api/record-page.mdx b/content/docs/references/api/record-page.mdx index 7c56e4bec72..b13efa87f67 100644 --- a/content/docs/references/api/record-page.mdx +++ b/content/docs/references/api/record-page.mdx @@ -43,10 +43,15 @@ their own routes. [#28737] The `timeline` part is the size of the record's 动态 stream — the comments, the activity and the field changes it merges — counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so a page draws the stream's count without holding every row of it. The total -counts the sources' rows as that schema's table defines them, so it is an +counts the sources' rows as that schema's table defines them, one action +that wrote both an activity row and a ledger row counted once, so it is an upper bound of the entries the 动态 panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff and so draws no entry. +[#30343] One case falls the other way: a create (delete) whose activity row +and ledger row the panel does not pair, as their actors differ or they were +stamped more than its pairing window apart, draws one entry more than the +total counts. 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 @@ -436,7 +441,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. | | **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). | -| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. | +| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside, and as many `created` (`deleted`) rows as it has `create` (`delete`) writes. | --- @@ -548,7 +553,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **total** | `integer` | ✅ | The rows of the sources the request named, counted as this caller may read them: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff. | +| **total** | `integer` | ✅ | The rows of the sources the request named, counted as this caller may read them, one action that wrote both an activity row and a ledger row counted once: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff; it draws one more for a create or delete whose two rows it does not pair. | ### Nested Shape: `RecordPageResponse.timeline[option 2]` @@ -596,7 +601,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **total** | `integer` | ✅ | The rows of the sources the request named, counted as this caller may read them: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff. | +| **total** | `integer` | ✅ | The rows of the sources the request named, counted as this caller may read them, one action that wrote both an activity row and a ledger row counted once: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff; it draws one more for a create or delete whose two rows it does not pair. | --- @@ -609,7 +614,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. | | **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). | -| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. | +| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside, and as many `created` (`deleted`) rows as it has `create` (`delete`) writes. | --- diff --git a/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts b/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts index e626b174366..c98ad8f279a 100644 --- a/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts +++ b/packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts @@ -8,7 +8,8 @@ // The count is of rows the caller could read, and of no other: it equals the // sum of the separate list reads it stands for, asked as the same member (the // spec's timeline table), and the activity's `updated` rows step aside once a -// field change is counted. A refused ledger read counting no field change is +// field change is counted, as do its `created` rows paired with a `create` +// ledger write (#30343: one action is one entry). A refused ledger read counting no field change is // pinned at the route (`record-page-routes.test.ts`): on this boot the // platform's member floor reads the ledger rows of a record the member reads. @@ -17,7 +18,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; -import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; +import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed } from './armed.js'; import { catalogPermissionSet } from './catalog-permission-set.js'; @@ -119,9 +120,23 @@ describe('[#28737] the timeline part counts the 动态 stream the caller could r const changes = (await totalAs(token, 'sys_audit_log', { ...ofRecord, action: { $in: ['create', 'update', 'delete'] } })) ?? 0; const drawn = await totalAs(token, 'sys_activity', { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } }); const updates = await totalAs(token, 'sys_activity', { ...ofRecord, type: 'updated' }); + // A create (a delete) steps aside as often as both sources hold one. + let paired = 0; + for (const action of ['create', 'delete'] as const) { + const rows = (await totalAs(token, 'sys_activity', { ...ofRecord, type: RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action] })) ?? 0; + const writes = (await totalAs(token, 'sys_audit_log', { ...ofRecord, action })) ?? 0; + paired += Math.min(rows, writes); + } expect(comments, 'the comment read answers').not.toBeNull(); expect(drawn, 'the activity read answers').not.toBeNull(); - return { comments: comments!, changes, drawn: drawn!, updates: updates!, total: comments! + changes + drawn! - (changes > 0 ? updates! : 0) }; + return { + comments: comments!, + changes, + drawn: drawn!, + updates: updates!, + paired, + total: comments! + changes + drawn! - (changes > 0 ? updates! + paired : 0), + }; }; const timelineAs = async (token: string) => { const res = await stack.apiAs(token, 'POST', `/data/cmt_open/${recordId}/page`, { timeline: ALL_SOURCES }); @@ -135,6 +150,8 @@ describe('[#28737] the timeline part counts the 动态 stream the caller could r expect(expected.comments).toBe(3); expect(expected.changes).toBeGreaterThanOrEqual(3); expect(expected.updates).toBe(2); + // The record's one create is written to both sources and counts once. + expect(expected.paired).toBe(1); expect(await timelineAs(authorTok)).toEqual({ total: expected.total }); }, 60_000); diff --git a/packages/rest/src/record-page-routes.test.ts b/packages/rest/src/record-page-routes.test.ts index 5cab6fe165f..c34bf98ea11 100644 --- a/packages/rest/src/record-page-routes.test.ts +++ b/packages/rest/src/record-page-routes.test.ts @@ -26,13 +26,16 @@ * - [#28737] the timeline part is a count of each source the body names, by * that source's own read as the caller, summed as the spec's timeline table * spells it. + * - [#30343] one action written to both the activity and the ledger is one + * entry of that count: every update steps aside, and a create (a delete) as + * often as both sources hold one. * * 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`. */ import { describe, it, expect, vi } from 'vitest'; -import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; +import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server.js'; import { RECORD_PAGE_PARAMS } from './record-page-routes.js'; import { unknownQueryParamMessage } from './query-allowlist.js'; @@ -601,8 +604,10 @@ describe('[#28737] the timeline part is the 动态 stream\'s size, counted by th const countOf = (req: any) => { const where = req.query?.where ?? {}; if (req.object === 'sys_comment') return { records: [], total: 3 }; - if (req.object === 'sys_audit_log') return { records: [], total: 40 }; - if (req.object === 'sys_activity') return { records: [], total: where.type === 'updated' ? 30 : 250 }; + if (req.object === 'sys_audit_log') return { records: [], total: where.action === 'create' ? 1 : where.action === 'delete' ? 0 : 40 }; + if (req.object === 'sys_activity') { + return { records: [], total: where.type === 'updated' ? 30 : where.type === 'created' ? 1 : where.type === 'deleted' ? 2 : 250 }; + } return { records: [], total: 0 }; }; const ofRecord = { object_name: 'account', record_id: 'a1' }; @@ -612,8 +617,9 @@ describe('[#28737] the timeline part is the 动态 stream\'s size, counted by th const answer = await call({ timeline: { discussion: true, activity: true, fieldChanges: true } }); expect(answer.status).toBe(200); const data = RecordPageResponseSchema.parse(answer.body.data); - // 3 comments + 40 field changes + 250 drawn activity rows − their 30 updates. - expect(data.timeline).toEqual({ total: 263 }); + // 3 comments + 40 field changes + 250 drawn activity rows − their 30 + // updates − min(1 created, 1 create) − min(2 deleted, 0 delete) [#30343]. + expect(data.timeline).toEqual({ total: 262 }); const reads = findData.mock.calls.map(([req]) => req); expect(reads).toEqual(expect.arrayContaining([ { object: 'sys_comment', query: { object: 'sys_comment', where: { thread_id: 'account:a1' }, limit: 0, count: true }, context: CTX }, @@ -637,8 +643,20 @@ describe('[#28737] the timeline part is the 动态 stream\'s size, counted by th query: { object: 'sys_activity', where: { ...ofRecord, type: 'updated' }, limit: 0, count: true }, context: CTX, }, + ...(['create', 'delete'] as const).flatMap((action) => [ + { + object: 'sys_activity', + query: { object: 'sys_activity', where: { ...ofRecord, type: RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action] }, limit: 0, count: true }, + context: CTX, + }, + { + object: 'sys_audit_log', + query: { object: 'sys_audit_log', where: { ...ofRecord, action }, limit: 0, count: true }, + context: CTX, + }, + ]), ])); - expect(reads).toHaveLength(4); + expect(reads).toHaveLength(8); }); it('counts only the sources named, and no update steps aside without a field change', async () => { @@ -731,3 +749,79 @@ describe('[#28737] the timeline part is the 动态 stream\'s size, counted by th expect(findData).not.toHaveBeenCalled(); }); }); + +describe('[#30343] one action is one entry in the timeline count, as the 动态 stream draws it', () => { + const items: Record = { ...ITEMS, sys_audit_log: systemObject('sys_audit_log') }; + const ofRecord = { object_name: 'account', record_id: 'a1' }; + // Rows, not canned totals: each count read is answered by filtering these + // with its own `where`, so the sum is the one the rows give. + // It implements the field equality, `$in` and `$nin` the route sends, and + // refuses anything else (a combinator, another operator) rather than + // misreading it. + const matches = (row: Record, where: Record) => + Object.entries(where).every(([key, want]) => { + if (key.startsWith('$')) throw new Error(`the timeline double does not implement the combinator ${key}`); + if (want && typeof want === 'object') { + const ops = Object.keys(want); + if (ops.length !== 1 || (ops[0] !== '$in' && ops[0] !== '$nin')) { + throw new Error(`the timeline double does not implement ${ops.join(', ')}`); + } + return ops[0] === '$in' ? want.$in.includes(row[key]) : !want.$nin.includes(row[key]); + } + return row[key] === want; + }); + const rowsBoot = (rows: Record>>, refuse: string[] = []) => + boot({ + items, + findData: async (req) => { + if (refuse.includes(req.object)) throw permissionDenied(); + const where = req.query?.where ?? {}; + return { records: [], total: (rows[req.object] ?? []).filter((row) => matches(row, where)).length }; + }, + }); + // One create and one edit, each written to both the activity and the ledger. + const createThenEdit = { + sys_activity: [ + { ...ofRecord, type: 'created' }, + { ...ofRecord, type: 'updated' }, + ], + sys_audit_log: [ + { ...ofRecord, action: 'create' }, + { ...ofRecord, action: 'update' }, + ], + }; + const BOTH = { timeline: { discussion: true, activity: true, fieldChanges: true } }; + + it('an audit reader counts one create and one edit as 2, the rows the stream draws', async () => { + const { call } = rowsBoot(createThenEdit); + const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data); + expect(data.timeline).toEqual({ total: 2 }); + }); + + it('a member without audit read counts the same 2 activity rows, unchanged', async () => { + const { call } = rowsBoot(createThenEdit, ['sys_audit_log']); + const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data); + expect(data.timeline).toEqual({ total: 2 }); + }); + + it('a record created before its history was kept counts its create row: one create and one edit are still 2', async () => { + const { call } = rowsBoot({ ...createThenEdit, sys_audit_log: [{ ...ofRecord, action: 'update' }] }); + const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data); + expect(data.timeline).toEqual({ total: 2 }); + }); + + it('a delete written to both sources is one entry too: create, edit and delete count 3', async () => { + const { call } = rowsBoot({ + sys_activity: [...createThenEdit.sys_activity, { ...ofRecord, type: 'deleted' }], + sys_audit_log: [...createThenEdit.sys_audit_log, { ...ofRecord, action: 'delete' }], + }); + const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data); + expect(data.timeline).toEqual({ total: 3 }); + }); + + it('a comment still adds one', async () => { + const { call } = rowsBoot({ ...createThenEdit, sys_comment: [{ thread_id: 'account:a1' }] }); + const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data); + expect(data.timeline).toEqual({ total: 3 }); + }); +}); diff --git a/packages/rest/src/record-page-routes.ts b/packages/rest/src/record-page-routes.ts index 9f7d11e1baf..ffe994f78d0 100644 --- a/packages/rest/src/record-page-routes.ts +++ b/packages/rest/src/record-page-routes.ts @@ -34,6 +34,7 @@ */ import { + RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, RecordPageRequestSchema, @@ -359,7 +360,12 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: // deployment does not serve holds no entry, as the page reads // such a stream as an empty one; a refused comment or activity // count refuses the part, and a refused ledger count is no - // field change, as the stream then merges none. + // field change, as the stream then merges none. [#30343] The + // stream draws one action that wrote both an activity row and + // a ledger row as one entry, so with a field change counted + // every update steps aside, and a create (a delete) steps + // aside as often as both sources hold one: the fewer of the + // two counts, as the stream pairs no more than that. const answerTimeline = async ( sources: NonNullable, ): Promise<{ total: number } | { error: PartRefusal }> => { @@ -369,22 +375,37 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath: return answer.error.code === 'OBJECT_NOT_FOUND' ? 0 : answer; }; const ofRecord = { object_name: object, record_id: id }; - const [comments, fieldChanges, activity, updates] = await Promise.all([ + const both = sources.activity && sources.fieldChanges; + /** The record's activity rows and ledger writes of one action, when both sources count. */ + const ofAction = (action: 'create' | 'delete') => + both + ? Promise.all([ + count(ACTIVITY_OBJECT, { ...ofRecord, type: RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action] }), + count(AUDIT_LOG_OBJECT, { ...ofRecord, action }), + ]) + : Promise.resolve([0, 0] as const); + const [comments, fieldChanges, activity, updates, creates, deletes] = await Promise.all([ sources.discussion ? count(COMMENT_OBJECT, { thread_id: `${object}:${id}` }) : 0, sources.fieldChanges ? count(AUDIT_LOG_OBJECT, { ...ofRecord, action: { $in: FIELD_CHANGE_ACTIONS } }) : 0, sources.activity ? count(ACTIVITY_OBJECT, { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } }) : 0, // The updates that step aside once a field change is counted. - sources.activity && sources.fieldChanges - ? count(ACTIVITY_OBJECT, { ...ofRecord, type: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE }) - : 0, + both ? count(ACTIVITY_OBJECT, { ...ofRecord, type: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE }) : 0, + ofAction('create'), + ofAction('delete'), ]); if (typeof comments !== 'number') return comments; if (typeof activity !== 'number') return activity; if (typeof updates !== 'number') return updates; + let paired = 0; + for (const [rows, writes] of [creates, deletes]) { + if (typeof rows !== 'number') return rows; + // A refused ledger count pairs nothing, as the stream then merges none. + paired += Math.min(rows, typeof writes === 'number' ? writes : 0); + } const changes = typeof fieldChanges === 'number' ? fieldChanges : 0; - return { total: comments + changes + activity - (changes > 0 ? updates : 0) }; + return { total: comments + changes + activity - (changes > 0 ? updates + paired : 0) }; }; const answerFieldAccess = async () => { diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 09564c4eee6..e8f736317ec 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -884,6 +884,7 @@ "RECORD_PAGE_MAX_RELATED (const)", "RECORD_PAGE_MAX_RELATED_ROWS (const)", "RECORD_PAGE_MAX_RELATED_SORT (const)", + "RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES (const)", "RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES (const)", "RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE (const)", "REGISTERED_ERROR_CODES (const)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 4a74fce1563..60e29e5ce9d 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -855,6 +855,7 @@ "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)", + "RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES": "src/api/record-page.zod.ts#RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES (const)", "RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES": "src/api/record-page.zod.ts#RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES (const)", "RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE": "src/api/record-page.zod.ts#RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE (const)", "REGISTERED_ERROR_CODES": "src/api/error-code-ledger.zod.ts#REGISTERED_ERROR_CODES (const)", diff --git a/packages/spec/src/api/record-page.test.ts b/packages/spec/src/api/record-page.test.ts index 94d3af71eca..8747d9eca48 100644 --- a/packages/spec/src/api/record-page.test.ts +++ b/packages/spec/src/api/record-page.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { SYS_ACTIVITY_BUILTIN_TYPES } from '../data/feed.zod'; import { RECORD_PAGE_MAX_ACTIVITY_ROWS, + RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, RECORD_PAGE_MAX_ATTACHMENT_ROWS, @@ -145,4 +146,13 @@ describe('[#28737] the timeline part names its sources strictly and answers the expect(SYS_ACTIVITY_BUILTIN_TYPES as readonly string[]).toContain(RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE); expect(RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES as readonly string[]).not.toContain(RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE); }); + + it('[#30343] pairs each ledger write action with a drawn built-in activity type, the update one among them', () => { + expect(Object.keys(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES).sort()).toEqual(['create', 'delete', 'update']); + expect(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES.update).toBe(RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE); + for (const type of Object.values(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES)) { + expect(SYS_ACTIVITY_BUILTIN_TYPES as readonly string[], type).toContain(type); + expect(RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES as readonly string[], type).not.toContain(type); + } + }); }); diff --git a/packages/spec/src/api/record-page.zod.ts b/packages/spec/src/api/record-page.zod.ts index d0c4d6a55d2..a68d4dc11b6 100644 --- a/packages/spec/src/api/record-page.zod.ts +++ b/packages/spec/src/api/record-page.zod.ts @@ -44,10 +44,15 @@ import type { SysActivityBuiltinType } from '../data/feed.zod'; * 动态 stream — the comments, the activity and the field changes it merges — * counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so * a page draws the stream's count without holding every row of it. The total - * counts the sources' rows as that schema's table defines them, so it is an + * counts the sources' rows as that schema's table defines them, one action + * that wrote both an activity row and a ledger row counted once, so it is an * upper bound of the entries the 动态 panel draws, not their number. The panel * draws fewer where its `showCompleted` / `types` configuration filters entries * out, and where a ledger row has no displayable diff and so draws no entry. + * [#30343] One case falls the other way: a create (delete) whose activity row + * and ledger row the panel does not pair, as their actors differ or they were + * stamped more than its pairing window apart, draws one entry more than the + * total counts. * 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 @@ -92,10 +97,29 @@ export const RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES = [ * [#28737] The `sys_activity.type` of an update, which steps aside in the * 动态 stream once the stream merges the record's field changes: the audit * entry says the same update with its before and after values, so an update - * is one entry. + * is one entry. Every update steps aside, the ones no audit entry pairs with + * included; a create or a delete steps aside only where its audit entry pairs + * with it (`RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`). */ export const RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE = 'updated' satisfies SysActivityBuiltinType; +/** + * [#30343] Each ledger write action (`sys_audit_log.action`) and the + * `sys_activity.type` the same action writes. The 动态 stream draws one action + * that wrote both as ONE entry: the activity row, carrying the audit entry's + * before and after values. The `timeline` count follows it: with any field + * change counted, every `updated` row steps aside + * (`RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE`), and so do as many `created` + * (`deleted`) rows as both the activity and the ledger hold for the record: + * the fewer of its `created` (`deleted`) rows and its `create` (`delete`) + * entries. + */ +export const RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES = { + create: 'created', + update: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, + delete: 'deleted', +} as const satisfies Readonly>; + /** The record-grained operations a record page's header asks about. */ export const RecordPageExplainOperationSchema = z.enum(['update', 'delete', 'transfer']); export type RecordPageExplainOperation = z.input; @@ -167,16 +191,21 @@ export type RecordPageActivityRequestParsed = z.infer z.object({ 'Count the record\'s activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`).', ), fieldChanges: z.boolean().describe( - 'Count the record\'s field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside.', + 'Count the record\'s field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside, and as many `created` (`deleted`) rows as it has `create` (`delete`) writes.', ), }).strict()); export type RecordPageTimelineRequest = z.input; @@ -316,15 +345,17 @@ export type RecordPageStreamRowsParsed = z.infer z.object({ total: z.number().int().min(0).describe( - 'The rows of the sources the request named, counted as this caller may read them: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff.', + 'The rows of the sources the request named, counted as this caller may read them, one action that wrote both an activity row and a ledger row counted once: an upper bound of the entries the timeline panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff; it draws one more for a create or delete whose two rows it does not pair.', ), }).strict()); export type RecordPageTimelineCount = z.input;