Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30343-timeline-one-action-one-entry.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) a read answer's count falls for the same rows; nothing an author or a caller sends changes, so there is nothing to migrate -->

- **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).
15 changes: 10 additions & 5 deletions content/docs/references/api/record-page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |


---
Expand Down Expand Up @@ -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]`

Expand Down Expand Up @@ -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. |


---
Expand All @@ -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. |


---
Expand Down
23 changes: 20 additions & 3 deletions packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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';
Expand Down Expand Up @@ -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 });
Expand All @@ -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);

Expand Down
106 changes: 100 additions & 6 deletions packages/rest/src/record-page-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' };
Expand All @@ -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 },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<string, any> = { ...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<string, unknown>, where: Record<string, any>) =>
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<string, Array<Record<string, unknown>>>, 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 });
});
});
Loading
Loading