Event Log
Event Log
Covers the full event log lifecycle: automatic event recording for all workspace mutations (page, block, tag,
attachment, layout, type, property operations), the three query surfaces (query_page_events, query_page_timeline,
query_timeline), timeline ordering and pagination, content-change vs structural-event classification, and the history
collapse mechanism driven by event_log_retention_days. This spec is P0 because the event log is the sole source of
workspace mutation history after the git-backed checkpoint system was removed. Regressions in event recording or
timeline queries leave users with no audit trail and no ability to review what changed in their workspace.
The event log is append-only and written fire-and-forget by the WriteEffectCoordinator after every successful
mutation. Queries are read-only. The three Tauri commands exposed to the frontend are query_page_events (raw events
for a page, ascending), query_page_timeline (unified structural + content timeline, newest-first), and
query_timeline (workspace-wide time-range query, ascending).
Preconditions
- HTTP bridge running on port 9990
- A workspace initialized via
initialize_workspacebefore each scenario - Bridge shim injected via
playwright.config.ts - The HTTP bridge exposes dedicated event log routes covering
query_page_events,query_page_timeline, andquery_timeline. All scenarios in this spec are exercisable via the bridge.
Scenarios
Seed: seed.spec.ts
1. Empty event log in a fresh workspace
A newly initialized workspace has no events recorded before any mutations.
Steps:
- Initialize a fresh workspace.
- Create one page titled “Anchor Page” (this will generate events — record its page ID from the creation response).
- Immediately query
query_page_timelinewith an unrelated page ID that was never created (a random UUID).
Expected: query_page_timeline for the nonexistent page returns an empty array []. The result set is empty, not
an error, because the page simply has no recorded events.
2. Page creation is recorded as a Page/Created event
Creating a page must write a Page + Created event to the event log with the page title captured in after_value.
Steps:
- Create a page titled “History Test Page” and note its page ID from the response.
- Call
query_page_eventswith that page ID (limit 100, offset 0).
Expected: At least one event is returned. The first event has entity_type = "page", event_type = "created", and
after_value = "History Test Page". The event has a valid UUID id and a non-null RFC 3339 timestamp.
3. Page metadata update is recorded as a Page/Updated event
Updating a page’s metadata (title, icon, or other page-level field via update_page) appends an Updated event.
Steps:
- Create “Update Target” and note its page ID.
- Call
query_page_eventsto verify the initialcreatedevent. - Update the page’s title to “Update Target Renamed” via
update_page. - Call
query_page_eventsagain for the same page ID.
Expected: The event list now contains two entries for this page: a created event and an updated event. The
updated event has entity_type = "page" and event_type = "updated". Events are ordered ascending by timestamp, so
created precedes updated.
4. Page rename is recorded as a Page/Renamed event with before and after values
Renaming a page via the rename_page pathway records before_value (old title) and after_value (new title).
Steps:
- Create “Old Name” and note its page ID.
- Rename the page from “Old Name” to “New Name” using the rename command.
- Call
query_page_timelinefor that page ID.
Expected: The timeline contains a StructuralEvent entry with event_type = "renamed",
before_value = "Old Name", and after_value = "New Name". The summary field reads
Renamed from "Old Name" to "New Name".
5. Page soft-delete is recorded as a Page/Deleted event
Soft-deleting a page via delete_page appends a Deleted event.
Steps:
- Create “Doomed Page” and note its page ID.
- Delete “Doomed Page” (soft delete via
delete_page). - Call
query_page_eventsfor that page ID with limit 100.
Expected: The event list contains a created event followed by a deleted event. The deleted event has
entity_type = "page" and event_type = "deleted".
6. Block content save is recorded as a Block/Updated event
Saving block content (Loro CRDT via save_block_content_by_id) records a Block + Updated event, classified as a
ContentChange in the unified timeline.
Steps:
- Create “Content History Page” and note its page ID.
- Save new content to one of the page’s blocks via
save_block_content_by_id. - Call
query_page_timelinefor that page ID.
Expected: The timeline contains at least one entry with entry_type = "content_change", entity_type = "block",
and event_type = "updated". The summary reads "Block content updated".
7. Page timeline distinguishes StructuralEvent from ContentChange
The unified page timeline merges structural events (page-level mutations) and content change indicators (block edits)
with correct entry_type values.
Steps:
- Create “Mixed Activity Page” and note its page ID.
- Rename the page to “Mixed Activity Page Renamed”.
- Save updated block content on the page.
- Call
query_page_timelinefor that page ID (limit 50).
Expected: The timeline contains at least two entries. The rename entry has entry_type = "structural_event". The
block content save has entry_type = "content_change". The entries are ordered newest-first (the most recent action
appears at index 0).
8. Tag assignment to a page is recorded as a PageTag/Assigned event
Assigning a tag to a page via assign_tag_to_page fires a PageTag + Assigned event on the page entity.
Steps:
- Create page “Tagging Subject” and note its page ID.
- Create a tag named “Draft” and note its tag ID.
- Assign the tag to the page.
- Call
query_page_eventsfor the page ID.
Expected: The event list contains a page_tag / assigned event for that page. The entity_id is the page ID (not
the tag ID). The after_value contains the tag ID string.
9. Tag removal from a page is recorded as a PageTag/Removed event
Removing a tag from a page via remove_tag_from_page appends a PageTag + Removed event.
Steps:
- Create page “Tag Remove Target” and note its page ID.
- Create a tag “WIP” and assign it to the page.
- Remove the tag from the page.
- Call
query_page_eventsfor the page ID.
Expected: The event list contains a page_tag / assigned event followed by a page_tag / removed event. The
removed event has entity_type = "page_tag" and event_type = "removed".
10. Attachment upload is recorded as an Attachment/Created event
Uploading an attachment via upload_attachment fires an Attachment + Created event.
Steps:
- Create page “Attachment Host” (any content).
- Upload a small attachment (e.g., a 1-pixel PNG) and note the returned attachment ID.
- Call
query_timelinewith a wide time range (e.g., the past 1 minute to 1 minute in the future) to capture all recent events.
Expected: The timeline contains at least one event with entity_type = "attachment" and event_type = "created".
The entity_id matches the attachment ID.
11. Workspace-wide timeline query returns events in ascending timestamp order
query_timeline returns all events within a time range, ordered ascending (oldest first).
Steps:
- Create three pages in sequence: “Alpha”, “Beta”, “Gamma”.
- Call
query_timelinewithstart_rfc3339set to 30 seconds before the first creation andend_rfc3339set to 30 seconds after the last creation, limit 200.
Expected: The returned events are ordered by timestamp ascending. The creation event for “Alpha” appears before “Beta”, which appears before “Gamma”. The array contains at least 3 events (one per page creation).
12. query_timeline rejects invalid RFC 3339 timestamps
Passing a non-RFC-3339 string as start_rfc3339 or end_rfc3339 must return a validation error.
Steps:
- Call
query_timelinewithstart_rfc3339 = "not-a-timestamp"andend_rfc3339 = "2099-01-01T00:00:00Z".
Expected: A validation error is returned. No events are returned in the response body.
13. query_timeline rejects start-after-end ranges
Providing a start_rfc3339 that is later than end_rfc3339 must return a validation error.
Steps:
- Call
query_timelinewithstart_rfc3339 = "2099-01-01T00:00:00Z"andend_rfc3339 = "2020-01-01T00:00:00Z".
Expected: A validation error is returned with message "start must be before or equal to end".
14. query_page_events rejects an invalid page UUID
Passing a non-UUID string as page_id must return a validation error before any database access.
Steps:
- Call
query_page_eventswithpage_id = "not-a-uuid".
Expected: A validation error is returned. No events are returned.
15. query_page_events is paginated and respects limit and offset
query_page_events supports limit and offset for pagination over large event sets.
Steps:
- Create “Busy Page” and note its page ID.
- Save block content on that page 5 times in rapid succession to generate 5 block-updated events (plus 1 page-created = 6 events total).
- Call
query_page_eventswith limit 3, offset 0. Record the 3 returned events. - Call
query_page_eventswith limit 3, offset 3. Record the next 3 events.
Expected: The first call returns exactly 3 events. The second call returns up to 3 more events (the exact count depends on total events, but at least 1). No event appears in both pages. Events across both pages are ordered ascending by timestamp.
16. query_page_timeline is ordered newest-first
query_page_timeline returns entries sorted newest-first (most recent action at index 0).
Steps:
- Create “Timeline Order Page” and note its page ID.
- Rename the page to “Timeline Order Page v2”.
- Save block content on the page.
- Call
query_page_timelinewith limit 50.
Expected: The first entry in the array is the block content save (most recent), and the page creation entry is last (oldest). Timestamps are strictly descending from index 0 to the end.
17. Page restore from trash is recorded as a Page/Restored event
Restoring a soft-deleted page via restore_page appends a Page + Restored event.
Steps:
- Create “Restore Me” and note its page ID.
- Delete it via
delete_page. - Restore it via
restore_page. - Call
query_page_eventsfor that page ID.
Expected: The event list contains three events in ascending order: created, deleted, restored. The restored
event has entity_type = "page" and event_type = "restored".
18. History collapse deletes events older than retention cutoff
The delete_before repository method removes all events with timestamp before the configured event_log_retention_days
cutoff.
Steps:
- Confirm the default
event_log_retention_daysis 90 days (viaget_settings). - Manually record (or artificially backdate) an event with a timestamp older than 90 days. (Note: this requires direct DB access or a test fixture.)
- Trigger a history collapse run.
- Query the timeline and confirm the old event is absent.
Expected: Events older than the retention cutoff are purged. Events within the retention window are preserved.
19. event_log_retention_days setting bounds are enforced
The event_log_retention_days setting accepts values between 7 and 3650 (inclusive). Values outside this range are
clamped.
Steps:
- Call
update_settingswithevent_log_retention_days = 0. - Retrieve settings and observe the actual stored value.
- Call
update_settingswithevent_log_retention_days = 99999. - Retrieve settings and observe the actual stored value.
Expected: Setting 0 is clamped to the minimum of 7. Setting 99999 is clamped to the maximum of 3650. No error is returned; the value is silently clamped to the valid range.
20. Page move is recorded as a Page/Moved event
Moving a page to a different parent via move_page records a Page + Moved event.
Steps:
- Create “Root Page” at the workspace root.
- Create “Child Page” as a child of “Root Page”.
- Create “New Parent” at the workspace root.
- Move “Child Page” to become a child of “New Parent” (record “Child Page” page ID).
- Call
query_page_eventsfor the “Child Page” page ID.
Expected: The event list for “Child Page” contains a created event followed by a moved event. The moved event
has entity_type = "page" and event_type = "moved".
Test Data
| Key | Value | Notes |
|---|---|---|
| default_retention_days | 90 | Default event_log_retention_days from Settings::default() |
| min_retention_days | 7 | MIN_EVENT_LOG_RETENTION_DAYS constant |
| max_retention_days | 3650 | MAX_EVENT_LOG_RETENTION_DAYS constant (10 years) |
| query_page_events_default_limit | 100 | Default limit for query_page_events when none specified |
| query_page_events_max_limit | 500 | Maximum capped limit for query_page_events |
| query_page_timeline_default_limit | 50 | Default limit for query_page_timeline when none specified |
| query_page_timeline_max_limit | 200 | Maximum capped limit for query_page_timeline |
| query_timeline_default_limit | 200 | Default limit for query_timeline when none specified |
| query_timeline_max_limit | 1000 | Maximum capped limit for query_timeline |
| page_timeline_sort_order | newest-first | query_page_timeline sorts descending; query_page_events sorts ascending |
| timeline_sort_order | ascending | query_timeline and query_page_events sort ascending by timestamp |
| content_change_trigger | EntityType::Block + EventType::Updated | Only Block+Updated maps to TimelineEntryType::ContentChange |
| structural_event_default | all other combinations | All other entity+event combos map to TimelineEntryType::StructuralEvent |
Notes
- The event log is append-only. There is no update or upsert operation. Events are immutable once written.
- The HTTP bridge exposes dedicated event log routes. Event emission is verifiable by triggering mutations through bridge-exposed page/tag/attachment routes and reading back events via the event log query routes.
- Events are recorded fire-and-forget by
WriteEffectCoordinator. If the event log write fails, the mutation still succeeds. This means a scenario that creates a page and then queries events could theoretically see 0 events if the log write failed silently. In practice this should not occur in test conditions. query_by_pagein the SQLite repository matches block events whereentity_id = page_id— this is the block’sentity_idas stored, not the page the block belongs to. In production,WriteEffectCoordinator.on_block_content_savedpasses theblock_idasentity_id. The mock test helpers usepage_idas a proxy. This distinction means integration tests must carefully track block IDs when verifying block events appear inquery_page_eventsresults.- History collapse (
delete_before) is triggered by theHistoryCollapseTaskbackground task, not on-demand via a command. Testing collapse requires either direct DB manipulation or a test mode that lowers the retention window to seconds. - The
event_log_summariestable (added in V003 migration) is referenced in the architecture overview but no query use case currently reads it — it is reserved for future collapse summary display. query_page_eventstakes apage_idUUID string, not a page slug. Callers must first resolve the slug to an ID viaget_page.
Was this page helpful?
Thanks for your feedback!