Skip to content
Documentation GitHub
Workspace

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_workspace before each scenario
  • Bridge shim injected via playwright.config.ts
  • The HTTP bridge exposes dedicated event log routes covering query_page_events, query_page_timeline, and query_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:

  1. Initialize a fresh workspace.
  2. Create one page titled “Anchor Page” (this will generate events — record its page ID from the creation response).
  3. Immediately query query_page_timeline with 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:

  1. Create a page titled “History Test Page” and note its page ID from the response.
  2. Call query_page_events with 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:

  1. Create “Update Target” and note its page ID.
  2. Call query_page_events to verify the initial created event.
  3. Update the page’s title to “Update Target Renamed” via update_page.
  4. Call query_page_events again 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:

  1. Create “Old Name” and note its page ID.
  2. Rename the page from “Old Name” to “New Name” using the rename command.
  3. Call query_page_timeline for 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:

  1. Create “Doomed Page” and note its page ID.
  2. Delete “Doomed Page” (soft delete via delete_page).
  3. Call query_page_events for 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:

  1. Create “Content History Page” and note its page ID.
  2. Save new content to one of the page’s blocks via save_block_content_by_id.
  3. Call query_page_timeline for 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:

  1. Create “Mixed Activity Page” and note its page ID.
  2. Rename the page to “Mixed Activity Page Renamed”.
  3. Save updated block content on the page.
  4. Call query_page_timeline for 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:

  1. Create page “Tagging Subject” and note its page ID.
  2. Create a tag named “Draft” and note its tag ID.
  3. Assign the tag to the page.
  4. Call query_page_events for 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:

  1. Create page “Tag Remove Target” and note its page ID.
  2. Create a tag “WIP” and assign it to the page.
  3. Remove the tag from the page.
  4. Call query_page_events for 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:

  1. Create page “Attachment Host” (any content).
  2. Upload a small attachment (e.g., a 1-pixel PNG) and note the returned attachment ID.
  3. Call query_timeline with 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:

  1. Create three pages in sequence: “Alpha”, “Beta”, “Gamma”.
  2. Call query_timeline with start_rfc3339 set to 30 seconds before the first creation and end_rfc3339 set 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:

  1. Call query_timeline with start_rfc3339 = "not-a-timestamp" and end_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:

  1. Call query_timeline with start_rfc3339 = "2099-01-01T00:00:00Z" and end_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:

  1. Call query_page_events with page_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:

  1. Create “Busy Page” and note its page ID.
  2. Save block content on that page 5 times in rapid succession to generate 5 block-updated events (plus 1 page-created = 6 events total).
  3. Call query_page_events with limit 3, offset 0. Record the 3 returned events.
  4. Call query_page_events with 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:

  1. Create “Timeline Order Page” and note its page ID.
  2. Rename the page to “Timeline Order Page v2”.
  3. Save block content on the page.
  4. Call query_page_timeline with 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:

  1. Create “Restore Me” and note its page ID.
  2. Delete it via delete_page.
  3. Restore it via restore_page.
  4. Call query_page_events for 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:

  1. Confirm the default event_log_retention_days is 90 days (via get_settings).
  2. Manually record (or artificially backdate) an event with a timestamp older than 90 days. (Note: this requires direct DB access or a test fixture.)
  3. Trigger a history collapse run.
  4. 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:

  1. Call update_settings with event_log_retention_days = 0.
  2. Retrieve settings and observe the actual stored value.
  3. Call update_settings with event_log_retention_days = 99999.
  4. 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:

  1. Create “Root Page” at the workspace root.
  2. Create “Child Page” as a child of “Root Page”.
  3. Create “New Parent” at the workspace root.
  4. Move “Child Page” to become a child of “New Parent” (record “Child Page” page ID).
  5. Call query_page_events for 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

KeyValueNotes
default_retention_days90Default event_log_retention_days from Settings::default()
min_retention_days7MIN_EVENT_LOG_RETENTION_DAYS constant
max_retention_days3650MAX_EVENT_LOG_RETENTION_DAYS constant (10 years)
query_page_events_default_limit100Default limit for query_page_events when none specified
query_page_events_max_limit500Maximum capped limit for query_page_events
query_page_timeline_default_limit50Default limit for query_page_timeline when none specified
query_page_timeline_max_limit200Maximum capped limit for query_page_timeline
query_timeline_default_limit200Default limit for query_timeline when none specified
query_timeline_max_limit1000Maximum capped limit for query_timeline
page_timeline_sort_ordernewest-firstquery_page_timeline sorts descending; query_page_events sorts ascending
timeline_sort_orderascendingquery_timeline and query_page_events sort ascending by timestamp
content_change_triggerEntityType::Block + EventType::UpdatedOnly Block+Updated maps to TimelineEntryType::ContentChange
structural_event_defaultall other combinationsAll 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_page in the SQLite repository matches block events where entity_id = page_id — this is the block’s entity_id as stored, not the page the block belongs to. In production, WriteEffectCoordinator.on_block_content_saved passes the block_id as entity_id. The mock test helpers use page_id as a proxy. This distinction means integration tests must carefully track block IDs when verifying block events appear in query_page_events results.
  • History collapse (delete_before) is triggered by the HistoryCollapseTask background 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_summaries table (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_events takes a page_id UUID string, not a page slug. Callers must first resolve the slug to an ID via get_page.

Was this page helpful?