You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 29702d5
Browse filesBrowse the repository at this point in the historyBrowse 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>
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).
Copy file name to clipboardExpand all lines: content/docs/references/api/record-page.mdx
+10-5Lines changed: 10 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -43,10 +43,15 @@ their own routes. [#28737] The `timeline` part is the size of the record's
43
43
动态 stream — the comments, the activity and the field changes it merges —
44
44
counted by those reads as the caller (`RecordPageTimelineRequestSchema`), so
45
45
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
47
48
upper bound of the entries the 动态 panel draws, not their number. The panel
48
49
draws fewer where its `showCompleted` / `types` configuration filters entries
49
50
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.
50
55
Object permissions, row-level security, sharing and
51
56
field-level security therefore apply exactly as they apply to those reads.
52
57
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:
436
441
| :--- | :--- | :--- | :--- |
437
442
|**discussion**|`boolean`| ✅ | Count the record's comment thread: every comment and reply. |
438
443
|**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. |
440
445
441
446
442
447
---
@@ -548,7 +553,7 @@ This schema accepts one of the following structures:
548
553
549
554
| Property | Type | Required | Description |
550
555
| :--- | :--- | :--- | :--- |
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. |
@@ -596,7 +601,7 @@ This schema accepts one of the following structures:
596
601
597
602
| Property | Type | Required | Description |
598
603
| :--- | :--- | :--- | :--- |
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. |
600
605
601
606
602
607
---
@@ -609,7 +614,7 @@ This schema accepts one of the following structures:
609
614
| :--- | :--- | :--- | :--- |
610
615
|**discussion**|`boolean`| ✅ | Count the record's comment thread: every comment and reply. |
611
616
|**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. |
0 commit comments