Skip to content

Commit 29702d5

Browse files
fix(spec,rest): the record page's timeline count counts one create or delete written to both sources once (#30405)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgVEexkPEJm3GPaXd2RgJee7)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:记录页「动态 N」角标对有审计读权限的人,一次「新建」或「删除」只算一条了(此前活动行和审计行各算一次)。一建一改现在显示「动态 2」,和面板画出的两行一致。 - 为什么改:PR #30342 已让动态流把一次操作画成一行,但服务端计数仍把新建算两次,管理员对数时看到「动态 3」压在两行上,会以为丢了条目。 - 风险与回滚:只改 REST 的计数和 spec 的公式说明,不改数据、不改写入。有字段变更时每条 updated 照旧让位;created/deleted 按「活动条数与审计条数取小」让位,所以开启字段历史前就建的记录不会少算。唯一可能少算一行的情形:同一次新建/删除的两条记录操作人不同或间隔超过 5 秒,客户端不配对(spec 已写明)。回滚即撤本 PR。 - 先看:`packages/rest/src/record-page-routes.ts` 的 `answerTimeline`(`ofAction` 与 `paired`);`packages/spec/src/api/record-page.zod.ts` 的 `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES` 与计数表;`packages/rest/src/record-page-routes.test.ts` 的 `[#30343]` 块。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/record-page-routes.test.ts -t 30343`,预期 5 条全绿;其中「一建一改 → 2」在 main 上得 3。 - 需要你做的:无,席位落地。 Fixes #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 (`RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` keeps its name and role). Metadata-protocol: none — a read answer's count, not anything an author writes. Skills-docs: none — `git grep -nE 'RECORD_TIMELINE|fieldChanges|timeline.*(total|count)|step aside' -- skills/` has 0 hits. ## What changes **The ruling.** Option A as the seat's PM discretion, sharpened after a measurement. PR #30342's client fold (`ui/packages/plugin-detail/src/recordTimelineStream.ts`, `pairActions` and `mergeTimelineStream`) is a per-row pairing, not a type rule: an audit `create`/`update`/`delete` entry pairs with a `created`/`updated`/`deleted` activity row of the same actor stamped within `TIMELINE_PAIR_WINDOW_MS` (5 s), nearest first, each once. On top, with any field change, an unpaired `updated` row still steps aside; an unpaired `created`/`deleted` row stays. A count-only server cannot pair rows. So the seat ruled (seat thread, 2026-11-05T08:04Z) the closest countable rule: with a field change counted, step aside every `updated` row, plus `min(created rows, create writes)` and `min(deleted rows, delete writes)`. This is exact whenever plugin-audit writes both rows in one call (the common case). Option A taken literally (every `created`/`deleted` row steps aside) would under-count a record created before its history was kept: the client draws `created` + the edit (2), A would count 1. **REST.** `answerTimeline` reads four more counts when both `activity` and `fieldChanges` are named: `sys_activity` by `type` `created` and `deleted`, `sys_audit_log` by `action` `create` and `delete`, each as the caller through `readStream`, in the same `Promise.all`. A refused ledger count pairs nothing (as a refused ledger total already counts no field change); a refused activity count refuses the part, as before. **Spec.** The table row, before: ``` | `activity` | `sys_activity` where `object_name` = `{object}`, `record_id` = `{id}` and `type` is not one | | | of `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`; with any field change counted, not | | | `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` either | ``` after: ``` | `activity` | `sys_activity` where `object_name` = `{object}`, `record_id` = `{id}` and `type` is not one | | | of `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`; with any field change counted, less every | | | `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` row, and less, for `created` and for `deleted` | | | (`RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`), the fewer of that type's rows and the ledger's | | | writes of the paired action (`create`, `delete`) | ``` The new constant `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES = { create: 'created', update: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, delete: 'deleted' }` sits beside the update one, whose docblock now says every update steps aside and a create or delete only where paired. The "upper bound" sentences (module docblock, table text, `RecordPageTimelineCountSchema` docblock and `total`'s `describe()`) keep the `showCompleted` / `types` / no-diff cases and gain the one case that falls the other way: a create (delete) whose two rows the panel does not pair. `fieldChanges`'s `describe()` names the created/deleted step-aside. `content/docs/references/api/record-page.mdx`, `api-surface/api.json` and `export-origins/api.json` are the generators' output. ## Tests - `packages/rest/src/record-page-routes.test.ts` `[#30343]` block, row-backed (each count filtered from rows by its own `where`): one create + one edit, audit reader → 2 (red on main: 3); same rows, ledger refused (member without audit read) → 2, unchanged; created before history (no `create` write) + one edit → 2; create + edit + delete → 3; a comment still adds one → 3. The `[#28737]` read-set pin now lists the 8 reads and its sum (262) carries the paired create. - `packages/spec/src/api/record-page.test.ts`: the new map pairs the three write actions with drawn built-in types, `update` being `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE`. - `packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts`: the expected sum from separate list reads adds the paired step-aside; on the real producers (one create, two renames) `paired` = 1. - Ablation (one run, then restored): `paired += Math.min(…)` replaced by `paired += 0` through `scripts/ablation-replace.mjs` → 4 failed / 33 passed in `record-page-routes.test.ts` (the 262 pin, create+edit → 2, create+edit+delete → 3, comment → 3); restore verified blob == HEAD. ## Acceptance notes - Deviation from the order's file surface: `packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts` is the route's end-to-end pin of this total (its expected formula is the spec table's), so it moves with the formula (os-dev: a stale fixture made red by this change is fixed in the same PR). No `ui/**` file changed: the one ui test that names the constant (`RecordTimelineTotal-28737.test.tsx`) pins the client fold, not the server total. - Measured on the way: `check:liveness` (and so `check:generated`'s liveness entry and `check-liveness.test.ts`) was red on `origin/main` fdad178 (`query/searchFields → packages/objectql/src/engine.ts`, after #30341). #30368 repointed it; after merging `origin/main` (8fad7b1) all three are green here. Not this PR's. ## Verification Head `821fd5381` (merge of `origin/main` 8fad7b1; regenerating spec build + `gen:api-surface` + `gen:export-origins` + `gen:docs` + `gen:liveness-counts` on it wrote no diff). <details><summary>Derived gates: 104 derived, 104 run, 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --ran` on `dc2c02ab8`: `✓ 104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED`. The loop ran on `1c674657`; the two commits after it (the changeset's `adr-0087:` marker, the test double refusing combinators) were answered by re-running the gates they touch. 102 exit 0 on the pre-merge tree; the 2 red (`check:liveness`, `check:generated`) were the main red above. Re-run on the merged head `821fd5381`, all exit 0: `check:liveness`, `check:generated`, `check:api-surface`, `check:export-origins`, `check:docs`, `check-adr-0087-registration --base origin/main`, `check:where-matcher`, `check-changeset-no-major`. `check:skill-examples`, `check:dual-build-cjs-loads` and `check-plugin-teardown-shape --self-test` exit 0 after a whole-workspace build and the pinned-commit fetch. Lists: project files `server-seat5/gates-30343-*.txt`. </details> <details><summary>Suites, lint, dogfood</summary> - `pnpm --filter @objectstack/rest test`: 386 files, 7335 passed, 332 skipped. `record-page-routes.test.ts` on the merged head: 37/37. - `pnpm --filter @objectstack/rest typecheck`, `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/spec test`: 25553 passed, 24 failed, all 24 in `scripts/liveness/check-liveness.test.ts` (the main red above). On the merged head, `record-page.test.ts` + `check-liveness.test.ts`: 85/85. - `pnpm lint`: exit 0. - Full dogfood (narrowing arm), `pnpm --filter @objectstack/dogfood test` under the lock after `turbo run build --filter='!@objectstack/docs'`, on `dc2c02ab8`: `VERDICT command-exit 0`, Test Files 419 passed / 3 skipped, Tests 3492 passed / 36 skipped, 0 failed. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYeVL11pNodMuPhy8q --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a91409c commit 29702d5

9 files changed

Lines changed: 226 additions & 31 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
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)
7+
8+
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.
9+
Metadata-protocol: none — a read answer's count, not anything an author writes.
10+
11+
<!-- 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 -->
12+
13+
- **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).
14+
- **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.
15+
- **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).

‎content/docs/references/api/record-page.mdx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,15 @@ their own routes. [#28737] The `timeline` part is the size of the record's
4343
动态 stream — the comments, the activity and the field changes it merges —
4444
counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so
4545
a page draws the stream's count without holding every row of it. The total
46-
counts the sources' rows as that schema's table defines them, so it is an
46+
counts the sources' rows as that schema's table defines them, one action
47+
that wrote both an activity row and a ledger row counted once, so it is an
4748
upper bound of the entries the 动态 panel draws, not their number. The panel
4849
draws fewer where its `showCompleted` / `types` configuration filters entries
4950
out, and where a ledger row has no displayable diff and so draws no entry.
51+
[#30343] One case falls the other way: a create (delete) whose activity row
52+
and ledger row the panel does not pair, as their actors differ or they were
53+
stamped more than its pairing window apart, draws one entry more than the
54+
total counts.
5055
Object permissions, row-level security, sharing and
5156
field-level security therefore apply exactly as they apply to those reads.
5257
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:
436441
| :--- | :--- | :--- | :--- |
437442
| **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. |
438443
| **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). |
439-
| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. |
444+
| **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. |
440445

441446

442447
---
@@ -548,7 +553,7 @@ This schema accepts one of the following structures:
548553

549554
| Property | Type | Required | Description |
550555
| :--- | :--- | :--- | :--- |
551-
| **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. |
556+
| **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. |
552557

553558
### Nested Shape: `RecordPageResponse.timeline[option 2]`
554559

@@ -596,7 +601,7 @@ This schema accepts one of the following structures:
596601

597602
| Property | Type | Required | Description |
598603
| :--- | :--- | :--- | :--- |
599-
| **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. |
604+
| **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. |
600605

601606

602607
---
@@ -609,7 +614,7 @@ This schema accepts one of the following structures:
609614
| :--- | :--- | :--- | :--- |
610615
| **discussion** | `boolean` | ✅ | Count the record's comment thread: every comment and reply. |
611616
| **activity** | `boolean` | ✅ | Count the record's activity rows the stream draws an entry for (not `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`). |
612-
| **fieldChanges** | `boolean` | ✅ | Count the record's field changes (its `sys_audit_log` writes); with any, its `updated` activity rows step aside. |
617+
| **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. |
613618

614619

615620
---

‎packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
// The count is of rows the caller could read, and of no other: it equals the
99
// sum of the separate list reads it stands for, asked as the same member (the
1010
// spec's timeline table), and the activity's `updated` rows step aside once a
11-
// field change is counted. A refused ledger read counting no field change is
11+
// field change is counted, as do its `created` rows paired with a `create`
12+
// ledger write (#30343: one action is one entry). A refused ledger read counting no field change is
1213
// pinned at the route (`record-page-routes.test.ts`): on this boot the
1314
// platform's member floor reads the ledger rows of a record the member reads.
1415

@@ -17,7 +18,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
1718
import { AuditPlugin } from '@objectstack/plugin-audit';
1819
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
1920
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
20-
import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api';
21+
import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api';
2122
import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js';
2223
import { armedWhen, assertArmed } from './armed.js';
2324
import { catalogPermissionSet } from './catalog-permission-set.js';
@@ -119,9 +120,23 @@ describe('[#28737] the timeline part counts the 动态 stream the caller could r
119120
const changes = (await totalAs(token, 'sys_audit_log', { ...ofRecord, action: { $in: ['create', 'update', 'delete'] } })) ?? 0;
120121
const drawn = await totalAs(token, 'sys_activity', { ...ofRecord, type: { $nin: [...RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES] } });
121122
const updates = await totalAs(token, 'sys_activity', { ...ofRecord, type: 'updated' });
123+
// A create (a delete) steps aside as often as both sources hold one.
124+
let paired = 0;
125+
for (const action of ['create', 'delete'] as const) {
126+
const rows = (await totalAs(token, 'sys_activity', { ...ofRecord, type: RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action] })) ?? 0;
127+
const writes = (await totalAs(token, 'sys_audit_log', { ...ofRecord, action })) ?? 0;
128+
paired += Math.min(rows, writes);
129+
}
122130
expect(comments, 'the comment read answers').not.toBeNull();
123131
expect(drawn, 'the activity read answers').not.toBeNull();
124-
return { comments: comments!, changes, drawn: drawn!, updates: updates!, total: comments! + changes + drawn! - (changes > 0 ? updates! : 0) };
132+
return {
133+
comments: comments!,
134+
changes,
135+
drawn: drawn!,
136+
updates: updates!,
137+
paired,
138+
total: comments! + changes + drawn! - (changes > 0 ? updates! + paired : 0),
139+
};
125140
};
126141
const timelineAs = async (token: string) => {
127142
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
135150
expect(expected.comments).toBe(3);
136151
expect(expected.changes).toBeGreaterThanOrEqual(3);
137152
expect(expected.updates).toBe(2);
153+
// The record's one create is written to both sources and counts once.
154+
expect(expected.paired).toBe(1);
138155
expect(await timelineAs(authorTok)).toEqual({ total: expected.total });
139156
}, 60_000);
140157

‎packages/rest/src/record-page-routes.test.ts‎

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
* - [#28737] the timeline part is a count of each source the body names, by
2727
* that source's own read as the caller, summed as the spec's timeline table
2828
* spells it.
29+
* - [#30343] one action written to both the activity and the ledger is one
30+
* entry of that count: every update steps aside, and a create (a delete) as
31+
* often as both sources hold one.
2932
*
3033
* The end-to-end half, under row-level and field-level security as a member who
3134
* is not an admin, is `record-page.dogfood.test.ts`.
3235
*/
3336

3437
import { describe, it, expect, vi } from 'vitest';
35-
import { RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api';
38+
import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES, RecordPageResponseSchema } from '@objectstack/spec/api';
3639
import { RestServer } from './rest-server.js';
3740
import { RECORD_PAGE_PARAMS } from './record-page-routes.js';
3841
import { unknownQueryParamMessage } from './query-allowlist.js';
@@ -601,8 +604,10 @@ describe('[#28737] the timeline part is the 动态 stream\'s size, counted by th
601604
const countOf = (req: any) => {
602605
const where = req.query?.where ?? {};
603606
if (req.object === 'sys_comment') return { records: [], total: 3 };
604-
if (req.object === 'sys_audit_log') return { records: [], total: 40 };
605-
if (req.object === 'sys_activity') return { records: [], total: where.type === 'updated' ? 30 : 250 };
607+
if (req.object === 'sys_audit_log') return { records: [], total: where.action === 'create' ? 1 : where.action === 'delete' ? 0 : 40 };
608+
if (req.object === 'sys_activity') {
609+
return { records: [], total: where.type === 'updated' ? 30 : where.type === 'created' ? 1 : where.type === 'deleted' ? 2 : 250 };
610+
}
606611
return { records: [], total: 0 };
607612
};
608613
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
612617
const answer = await call({ timeline: { discussion: true, activity: true, fieldChanges: true } });
613618
expect(answer.status).toBe(200);
614619
const data = RecordPageResponseSchema.parse(answer.body.data);
615-
// 3 comments + 40 field changes + 250 drawn activity rows − their 30 updates.
616-
expect(data.timeline).toEqual({ total: 263 });
620+
// 3 comments + 40 field changes + 250 drawn activity rows − their 30
621+
// updates − min(1 created, 1 create) − min(2 deleted, 0 delete) [#30343].
622+
expect(data.timeline).toEqual({ total: 262 });
617623
const reads = findData.mock.calls.map(([req]) => req);
618624
expect(reads).toEqual(expect.arrayContaining([
619625
{ 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
637643
query: { object: 'sys_activity', where: { ...ofRecord, type: 'updated' }, limit: 0, count: true },
638644
context: CTX,
639645
},
646+
...(['create', 'delete'] as const).flatMap((action) => [
647+
{
648+
object: 'sys_activity',
649+
query: { object: 'sys_activity', where: { ...ofRecord, type: RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action] }, limit: 0, count: true },
650+
context: CTX,
651+
},
652+
{
653+
object: 'sys_audit_log',
654+
query: { object: 'sys_audit_log', where: { ...ofRecord, action }, limit: 0, count: true },
655+
context: CTX,
656+
},
657+
]),
640658
]));
641-
expect(reads).toHaveLength(4);
659+
expect(reads).toHaveLength(8);
642660
});
643661

644662
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
731749
expect(findData).not.toHaveBeenCalled();
732750
});
733751
});
752+
753+
describe('[#30343] one action is one entry in the timeline count, as the 动态 stream draws it', () => {
754+
const items: Record<string, any> = { ...ITEMS, sys_audit_log: systemObject('sys_audit_log') };
755+
const ofRecord = { object_name: 'account', record_id: 'a1' };
756+
// Rows, not canned totals: each count read is answered by filtering these
757+
// with its own `where`, so the sum is the one the rows give.
758+
// It implements the field equality, `$in` and `$nin` the route sends, and
759+
// refuses anything else (a combinator, another operator) rather than
760+
// misreading it.
761+
const matches = (row: Record<string, unknown>, where: Record<string, any>) =>
762+
Object.entries(where).every(([key, want]) => {
763+
if (key.startsWith('$')) throw new Error(`the timeline double does not implement the combinator ${key}`);
764+
if (want && typeof want === 'object') {
765+
const ops = Object.keys(want);
766+
if (ops.length !== 1 || (ops[0] !== '$in' && ops[0] !== '$nin')) {
767+
throw new Error(`the timeline double does not implement ${ops.join(', ')}`);
768+
}
769+
return ops[0] === '$in' ? want.$in.includes(row[key]) : !want.$nin.includes(row[key]);
770+
}
771+
return row[key] === want;
772+
});
773+
const rowsBoot = (rows: Record<string, Array<Record<string, unknown>>>, refuse: string[] = []) =>
774+
boot({
775+
items,
776+
findData: async (req) => {
777+
if (refuse.includes(req.object)) throw permissionDenied();
778+
const where = req.query?.where ?? {};
779+
return { records: [], total: (rows[req.object] ?? []).filter((row) => matches(row, where)).length };
780+
},
781+
});
782+
// One create and one edit, each written to both the activity and the ledger.
783+
const createThenEdit = {
784+
sys_activity: [
785+
{ ...ofRecord, type: 'created' },
786+
{ ...ofRecord, type: 'updated' },
787+
],
788+
sys_audit_log: [
789+
{ ...ofRecord, action: 'create' },
790+
{ ...ofRecord, action: 'update' },
791+
],
792+
};
793+
const BOTH = { timeline: { discussion: true, activity: true, fieldChanges: true } };
794+
795+
it('an audit reader counts one create and one edit as 2, the rows the stream draws', async () => {
796+
const { call } = rowsBoot(createThenEdit);
797+
const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data);
798+
expect(data.timeline).toEqual({ total: 2 });
799+
});
800+
801+
it('a member without audit read counts the same 2 activity rows, unchanged', async () => {
802+
const { call } = rowsBoot(createThenEdit, ['sys_audit_log']);
803+
const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data);
804+
expect(data.timeline).toEqual({ total: 2 });
805+
});
806+
807+
it('a record created before its history was kept counts its create row: one create and one edit are still 2', async () => {
808+
const { call } = rowsBoot({ ...createThenEdit, sys_audit_log: [{ ...ofRecord, action: 'update' }] });
809+
const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data);
810+
expect(data.timeline).toEqual({ total: 2 });
811+
});
812+
813+
it('a delete written to both sources is one entry too: create, edit and delete count 3', async () => {
814+
const { call } = rowsBoot({
815+
sys_activity: [...createThenEdit.sys_activity, { ...ofRecord, type: 'deleted' }],
816+
sys_audit_log: [...createThenEdit.sys_audit_log, { ...ofRecord, action: 'delete' }],
817+
});
818+
const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data);
819+
expect(data.timeline).toEqual({ total: 3 });
820+
});
821+
822+
it('a comment still adds one', async () => {
823+
const { call } = rowsBoot({ ...createThenEdit, sys_comment: [{ thread_id: 'account:a1' }] });
824+
const data = RecordPageResponseSchema.parse((await call(BOTH)).body.data);
825+
expect(data.timeline).toEqual({ total: 3 });
826+
});
827+
});

0 commit comments

Comments
 (0)