From d31b979acff6da583b3b72ad93ac58b113362fa3 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 12:37:46 +0100 Subject: [PATCH 01/38] ENH: Adjust snapshots for using Automerge heads --- Cargo.lock | 5 + packages/backend/src/autosave.rs | 46 ++ packages/backend/src/document.rs | 43 +- packages/backend/src/lib.rs | 4 +- packages/backend/src/storage/mod.rs | 3 +- packages/backend/src/user_state.rs | 2 +- packages/backend/tests/migration_tests.rs | 393 ++++++++++++++++++ packages/backend/tests/user_state_tests.rs | 21 +- packages/migrator/Cargo.toml | 5 + packages/migrator/src/lib.rs | 3 + packages/migrator/src/main.rs | 4 +- .../m20260330000000_undo_redo_snapshots.rs | 351 ++++++++++++++++ packages/migrator/src/migrations/mod.rs | 2 + packages/migrator/src/storage/mod.rs | 5 + .../src/storage/postgres.rs | 0 .../src/automerge_json.rs | 40 -- packages/notebook-types/src/lib.rs | 3 + 17 files changed, 857 insertions(+), 73 deletions(-) create mode 100644 packages/backend/src/autosave.rs create mode 100644 packages/backend/tests/migration_tests.rs create mode 100644 packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs create mode 100644 packages/migrator/src/storage/mod.rs rename packages/{backend => migrator}/src/storage/postgres.rs (100%) rename packages/{backend => notebook-types}/src/automerge_json.rs (80%) diff --git a/Cargo.lock b/Cargo.lock index fc6a9d002..409b5c8d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2537,13 +2537,18 @@ name = "migrator" version = "0.1.0" dependencies = [ "async-trait", + "automerge", "clap", "dotenvy", + "notebook-types", + "samod", + "serde_json", "sqlx", "sqlx_migrator", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/packages/backend/src/autosave.rs b/packages/backend/src/autosave.rs new file mode 100644 index 000000000..2ed31a073 --- /dev/null +++ b/packages/backend/src/autosave.rs @@ -0,0 +1,46 @@ +//! Autosave listener that persists Automerge document changes to the database. + +use crate::app::AppState; +use crate::document::{RefContent, autosave}; +use futures_util::stream::StreamExt; +use notebook_types::automerge_json::hydrate_to_json; +use samod::DocHandle; +use uuid::Uuid; + +/// Spawns a background task that listens for document changes and triggers autosave. +pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: DocHandle) { + let listeners = state.active_listeners.read().await; + if listeners.contains(&ref_id) { + return; + } + + // Explicitly drop the read lock before acquiring write lock + drop(listeners); + + let mut listeners = state.active_listeners.write().await; + listeners.insert(ref_id); + + tokio::spawn({ + let state = state.clone(); + async move { + let mut changes = doc_handle.changes(); + + while (changes.next().await).is_some() { + let (hydrated, heads) = doc_handle.with_document(|doc| { + let heads: Vec> = + doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); + (doc.hydrate(None), heads) + }); + let content = hydrate_to_json(&hydrated); + + let data = RefContent { ref_id, content, heads }; + if let Err(e) = autosave(state.clone(), data).await { + tracing::error!("Autosave failed for ref {}: {:?}", ref_id, e); + } + } + + state.active_listeners.write().await.remove(&ref_id); + tracing::error!("Autosave listener stopped for ref {}", ref_id); + } + }); +} diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index e0290f0d9..e9ac487fc 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -1,7 +1,8 @@ //! Procedures to create and manipulate documents. use crate::app::{AppCtx, AppError, AppState}; -use crate::automerge_json::{ensure_autosave_listener, populate_automerge_from_json}; +use crate::autosave::ensure_autosave_listener; +use notebook_types::automerge_json::populate_automerge_from_json; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; use samod::DocumentId; @@ -41,6 +42,8 @@ pub async fn new_ref(ctx: AppCtx, content: Value) -> Result { let doc_handle = ctx.state.repo.create(automerge_doc).await?; let doc_id = doc_handle.document_id().to_string(); + let heads: Vec> = + doc_handle.with_document(|doc| doc.get_heads().iter().map(|h| h.0.to_vec()).collect()); // If the automerge-repo document is created but the db transaction doesn't complete, then the // document will be orphaned. The only negative consequence of that is additional space used, but @@ -52,18 +55,19 @@ pub async fn new_ref(ctx: AppCtx, content: Value) -> Result { sqlx::query!( " WITH snapshot AS ( - INSERT INTO snapshots(for_ref, content, last_updated, doc_id) - VALUES ($1, $2, NOW(), $3) + INSERT INTO snapshots(for_ref, content, last_updated, heads) + VALUES ($1, $2, NOW(), $4) RETURNING id ) - INSERT INTO refs(id, head, created) - VALUES ($1, (SELECT id FROM snapshot), NOW()) + INSERT INTO refs(id, current_snapshot, created, doc_id) + VALUES ($1, (SELECT id FROM snapshot), NOW(), $3) ", ref_id, // Use the JSON provided by automerge as the authoritative content // serde_json::to_value(doc_content), content, doc_id, + &heads, ) .execute(&mut *txn) .await?; @@ -99,7 +103,7 @@ pub async fn head_snapshot(state: AppState, ref_id: Uuid) -> Result Result<(), AppError> { - let RefContent { ref_id, content } = data; + let RefContent { ref_id, content, heads } = data; sqlx::query!( " UPDATE snapshots - SET content = $2, last_updated = NOW() - WHERE id = (SELECT head FROM refs WHERE id = $1) + SET content = $2, last_updated = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) ", ref_id, - content + content, + &heads, ) .execute(&state.db) .await?; @@ -175,7 +180,10 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr .await? .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - let cloned_doc = doc_handle.with_document(|doc| doc.clone()); + let (cloned_doc, heads) = doc_handle.with_document(|doc| { + let heads: Vec> = doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); + (doc.clone(), heads) + }); let cloned_handle = state.repo.create(cloned_doc).await?; let doc_content = head_snapshot(state.clone(), ref_id).await?; @@ -183,17 +191,18 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr sqlx::query!( " WITH snapshot AS ( - INSERT INTO snapshots(for_ref, content, last_updated, doc_id) - VALUES ($1, $2, NOW(), $3) + INSERT INTO snapshots(for_ref, content, last_updated, heads) + VALUES ($1, $2, NOW(), $4) RETURNING id ) UPDATE refs - SET head = (SELECT id FROM snapshot) + SET current_snapshot = (SELECT id FROM snapshot), doc_id = $3 WHERE id = $1 ", ref_id, doc_content, cloned_handle.document_id().to_string(), + &heads, ) .execute(&state.db) .await?; @@ -245,8 +254,7 @@ pub async fn restore_ref(state: AppState, ref_id: Uuid) -> Result<(), AppError> pub async fn get_doc_id(state: AppState, ref_id: Uuid) -> Result { let query = sqlx::query!( " - SELECT doc_id FROM snapshots - WHERE id = (SELECT head FROM refs WHERE id = $1) + SELECT doc_id FROM refs WHERE id = $1 ", ref_id ); @@ -276,4 +284,7 @@ pub struct RefContent { pub ref_id: Uuid, /// The JSON content of the document. pub content: Value, + /// Automerge heads (raw change hash bytes) at the time of this snapshot. + #[serde(skip)] + pub heads: Vec>, } diff --git a/packages/backend/src/lib.rs b/packages/backend/src/lib.rs index c706b2265..23a85b086 100644 --- a/packages/backend/src/lib.rs +++ b/packages/backend/src/lib.rs @@ -6,8 +6,8 @@ pub mod app; /// Authentication and authorization for document refs. pub mod auth; -/// Conversion between Automerge documents and JSON. -pub mod automerge_json; +/// Autosave listener for document changes. +pub mod autosave; /// Autosurgeon utilities for datetime serialization. pub mod autosurgeon_datetime; diff --git a/packages/backend/src/storage/mod.rs b/packages/backend/src/storage/mod.rs index 3d0d499a5..ea3baf2bb 100644 --- a/packages/backend/src/storage/mod.rs +++ b/packages/backend/src/storage/mod.rs @@ -1,7 +1,6 @@ //! Storage adapters for Automerge. -mod postgres; #[allow(missing_docs)] pub mod testing; -pub use postgres::PostgresStorage; +pub use migrator::storage::PostgresStorage; diff --git a/packages/backend/src/user_state.rs b/packages/backend/src/user_state.rs index 647129d46..35b49be20 100644 --- a/packages/backend/src/user_state.rs +++ b/packages/backend/src/user_state.rs @@ -254,7 +254,7 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result>" FROM filtered_ids JOIN refs ON refs.id = filtered_ids.id - JOIN snapshots ON snapshots.id = refs.head + JOIN snapshots ON snapshots.id = refs.current_snapshot ORDER BY refs.created DESC; "#, user_id, diff --git a/packages/backend/tests/migration_tests.rs b/packages/backend/tests/migration_tests.rs new file mode 100644 index 000000000..82122f2bd --- /dev/null +++ b/packages/backend/tests/migration_tests.rs @@ -0,0 +1,393 @@ +//! Integration tests for database migrations. +//! +//! These tests require a running PostgreSQL database and verify that +//! migrations apply correctly and produce the expected schema and data. +#[cfg(feature = "integration-tests")] +mod integration_tests { + + use sqlx::PgPool; + use sqlx::Row; + use sqlx_migrator::Info; + use sqlx_migrator::Plan; + use sqlx_migrator::migrator::{Migrate, Migrator}; + + /// Run all migrations on a test database pool. + async fn run_migrations(pool: &PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut migrator = Migrator::::default(); + migrator + .add_migrations(migrator::migrations()) + .expect("Failed to load migrations"); + + let plan = Plan::apply_all(); + migrator.run(&mut *conn, &plan).await.expect("Failed to run migrations"); + } + + /// Insert a test document ref with a snapshot, returning (ref_id, snapshot_id). + /// + /// Uses a CTE within a deferred transaction to handle the circular FK + /// between `refs.head` -> `snapshots.id` and `snapshots.for_ref` -> `refs.id`. + async fn insert_test_ref_with_snapshot( + pool: &PgPool, + content: serde_json::Value, + doc_id: &str, + ) -> (uuid::Uuid, i32) { + let ref_id = uuid::Uuid::now_v7(); + + let snapshot_id: i32 = sqlx::query_scalar( + "WITH snapshot AS ( + INSERT INTO snapshots(for_ref, content, last_updated, doc_id) + VALUES ($1, $2, NOW(), $3) + RETURNING id + ) + INSERT INTO refs(id, head, created) + VALUES ($1, (SELECT id FROM snapshot), NOW()) + RETURNING (SELECT id FROM snapshot)", + ) + .bind(ref_id) + .bind(&content) + .bind(doc_id) + .fetch_one(pool) + .await + .unwrap(); + + (ref_id, snapshot_id) + } + + /// Run all migrations except the undo-redo migration. + async fn run_migrations_before_undo_redo(pool: &PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut migrator = Migrator::::default(); + migrator + .add_migrations(migrator::migrations()) + .expect("Failed to load migrations"); + + // Apply all migrations, then revert the last one to get "before undo-redo" state. + let plan = Plan::apply_all(); + migrator.run(&mut *conn, &plan).await.expect("Failed to run migrations"); + + let plan = Plan::revert_count(1); + migrator.run(&mut *conn, &plan).await.expect("Failed to revert last migration"); + } + + /// Apply just the undo-redo migration (assumes all prior migrations are applied). + async fn apply_undo_redo_migration(pool: &PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut migrator = Migrator::::default(); + migrator + .add_migrations(migrator::migrations()) + .expect("Failed to load migrations"); + + let plan = Plan::apply_all(); + migrator.run(&mut *conn, &plan).await.expect("Failed to apply migration"); + } + + /// Revert the undo-redo migration. + async fn revert_undo_redo_migration(pool: &PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut migrator = Migrator::::default(); + migrator + .add_migrations(migrator::migrations()) + .expect("Failed to load migrations"); + + let plan = Plan::revert_count(1); + migrator.run(&mut *conn, &plan).await.expect("Failed to revert migration"); + } + + // ── Schema tests ── + + #[sqlx::test] + async fn test_migration_adds_heads_to_snapshots(pool: PgPool) { + run_migrations(&pool).await; + + // Verify the heads column exists on snapshots. + let row = sqlx::query( + "SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'heads'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let data_type: String = row.get("data_type"); + let is_nullable: String = row.get("is_nullable"); + assert_eq!(data_type, "ARRAY"); + assert_eq!(is_nullable, "NO"); + } + + #[sqlx::test] + async fn test_migration_moves_doc_id_to_refs(pool: PgPool) { + run_migrations(&pool).await; + + // doc_id should exist on refs. + let row = sqlx::query( + "SELECT column_name, is_nullable + FROM information_schema.columns + WHERE table_name = 'refs' AND column_name = 'doc_id'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let is_nullable: String = row.get("is_nullable"); + assert_eq!(is_nullable, "NO"); + + // doc_id should NOT exist on snapshots. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'doc_id'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count, 0); + } + + #[sqlx::test] + async fn test_migration_renames_head_to_current_snapshot(pool: PgPool) { + run_migrations(&pool).await; + + // current_snapshot should exist on refs. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'refs' AND column_name = 'current_snapshot'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count, 1); + + // head should NOT exist on refs. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'refs' AND column_name = 'head'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count, 0); + } + + // ── Data migration tests ── + + #[sqlx::test] + async fn test_migration_populates_heads_from_json_content(pool: PgPool) { + run_migrations_before_undo_redo(&pool).await; + + let content = serde_json::json!({ + "type": "model", + "version": "1", + "name": "Test Model", + "theory": "category", + "notebook": { + "cellOrder": [], + "cellContents": {} + } + }); + + let (_ref_id, snapshot_id) = + insert_test_ref_with_snapshot(&pool, content, "fake-doc-id-123").await; + + // Apply the undo-redo migration. + apply_undo_redo_migration(&pool).await; + + // The snapshot should now have heads populated. + let heads: Vec> = sqlx::query_scalar("SELECT heads FROM snapshots WHERE id = $1") + .bind(snapshot_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!heads.is_empty(), "heads should be populated"); + // Each head should be 32 bytes (SHA-256 hash). + for head in &heads { + assert_eq!(head.len(), 32, "head should be 32 bytes, got: {}", head.len()); + } + } + + #[sqlx::test] + async fn test_migration_preserves_doc_id_on_refs(pool: PgPool) { + run_migrations_before_undo_redo(&pool).await; + + let content = serde_json::json!({ + "type": "model", + "version": "1", + "name": "Test", + "theory": "category", + "notebook": { + "cellOrder": [], + "cellContents": {} + } + }); + + let original_doc_id = "test-doc-id-abc"; + let (ref_id, _snapshot_id) = + insert_test_ref_with_snapshot(&pool, content, original_doc_id).await; + + apply_undo_redo_migration(&pool).await; + + // refs should have a doc_id. Since the original doc_id doesn't exist + // in samod storage, the migration creates a new Automerge doc and + // updates the doc_id accordingly. + let stored_doc_id: String = sqlx::query_scalar("SELECT doc_id FROM refs WHERE id = $1") + .bind(ref_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!stored_doc_id.is_empty(), "doc_id should be populated"); + assert_ne!( + stored_doc_id, original_doc_id, + "doc_id should be updated to the newly created samod document" + ); + } + + #[sqlx::test] + async fn test_migration_handles_multiple_snapshots_per_ref(pool: PgPool) { + run_migrations_before_undo_redo(&pool).await; + + let doc_id = "multi-snapshot-doc-id"; + let content1 = serde_json::json!({"type": "model", "version": "1", "name": "V1", "theory": "category", "notebook": {"cellOrder": [], "cellContents": {}}}); + + let (ref_id, snapshot1_id) = insert_test_ref_with_snapshot(&pool, content1, doc_id).await; + + // Insert second snapshot (older version, not the head). + let snapshot2_id: i32 = sqlx::query_scalar( + "INSERT INTO snapshots(for_ref, content, last_updated, doc_id) + VALUES ($1, $2, NOW(), $3) RETURNING id", + ) + .bind(ref_id) + .bind(serde_json::json!({"type": "model", "version": "1", "name": "V0", "theory": "category", "notebook": {"cellOrder": [], "cellContents": {}}})) + .bind(doc_id) + .fetch_one(&pool) + .await + .unwrap(); + + apply_undo_redo_migration(&pool).await; + + // Both snapshots should have heads. + let heads1: Vec> = sqlx::query_scalar("SELECT heads FROM snapshots WHERE id = $1") + .bind(snapshot1_id) + .fetch_one(&pool) + .await + .unwrap(); + + let heads2: Vec> = sqlx::query_scalar("SELECT heads FROM snapshots WHERE id = $1") + .bind(snapshot2_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!heads1.is_empty()); + assert!(!heads2.is_empty()); + + // Different content should produce different heads. + assert_ne!(heads1, heads2); + } + + // ── Rollback tests ── + + #[sqlx::test] + async fn test_migration_rollback_restores_schema(pool: PgPool) { + run_migrations_before_undo_redo(&pool).await; + + let content = serde_json::json!({ + "type": "model", + "version": "1", + "name": "Rollback Test", + "theory": "category", + "notebook": { + "cellOrder": [], + "cellContents": {} + } + }); + + let doc_id = "rollback-doc-id"; + let (ref_id, _) = insert_test_ref_with_snapshot(&pool, content, doc_id).await; + + // Apply then revert. + apply_undo_redo_migration(&pool).await; + revert_undo_redo_migration(&pool).await; + + // head column should be back on refs. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'refs' AND column_name = 'head'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "head column should be restored"); + + // current_snapshot should be gone. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'refs' AND column_name = 'current_snapshot'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "current_snapshot should be gone after rollback"); + + // doc_id should be back on snapshots. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'doc_id'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "doc_id should be restored on snapshots"); + + // doc_id on snapshots should be repopulated and non-empty. + let snapshot_doc_id: String = sqlx::query_scalar( + "SELECT doc_id FROM snapshots + WHERE for_ref = $1 + LIMIT 1", + ) + .bind(ref_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!( + !snapshot_doc_id.is_empty(), + "snapshot doc_id should be populated after rollback" + ); + + // heads column should be gone from snapshots. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'heads'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "heads should be gone after rollback"); + } + + // ── get_ref_stubs removal test ── + + #[sqlx::test] + async fn test_migration_drops_get_ref_stubs(pool: PgPool) { + run_migrations(&pool).await; + + // The function should no longer exist. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.routines + WHERE routine_name = 'get_ref_stubs'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count, 0, "get_ref_stubs should be dropped"); + } +} diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index e802466a1..6d1f489a1 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -428,7 +428,7 @@ mod integration_tests { let updated = create_test_document_content("Updated Name"); document::autosave( state.clone(), - backend::document::RefContent { ref_id, content: updated }, + backend::document::RefContent { ref_id, content: updated, heads: vec![] }, ) .await .expect("Failed to autosave"); @@ -479,7 +479,7 @@ mod integration_tests { let updated = create_model_document_content("Theory Test", "petri-net"); document::autosave( state.clone(), - backend::document::RefContent { ref_id, content: updated }, + backend::document::RefContent { ref_id, content: updated, heads: vec![] }, ) .await .expect("Failed to autosave"); @@ -529,7 +529,7 @@ mod integration_tests { let updated = create_test_document_content("Updated by Autosave"); document::autosave( state.clone(), - backend::document::RefContent { ref_id, content: updated }, + backend::document::RefContent { ref_id, content: updated, heads: vec![] }, ) .await .expect("Failed to autosave"); @@ -1018,21 +1018,24 @@ mod integration_tests { content["theory"] = serde_json::Value::String(theory.clone()); } + // Use a fake heads value (32 zero bytes) for test data. + let fake_heads: Vec> = vec![vec![0u8; 32]]; sqlx::query!( r#" WITH snapshot AS ( - INSERT INTO snapshots (for_ref, content, last_updated, doc_id) - VALUES ($1, $2, $3, $4) + INSERT INTO snapshots (for_ref, content, last_updated, heads) + VALUES ($1, $2, $3, $5) RETURNING id ) - INSERT INTO refs (id, head, created) - VALUES ($1, (SELECT id FROM snapshot), $3) - ON CONFLICT (id) DO UPDATE SET head = (SELECT id FROM snapshot) + INSERT INTO refs (id, current_snapshot, created, doc_id) + VALUES ($1, (SELECT id FROM snapshot), $3, $4) + ON CONFLICT (id) DO UPDATE SET current_snapshot = (SELECT id FROM snapshot) "#, ref_id, content, doc.created_at, - format!("test_fake_automerge_doc_{ref_id}") + format!("test_fake_automerge_doc_{ref_id}"), + &fake_heads, ) .execute(db) .await?; diff --git a/packages/migrator/Cargo.toml b/packages/migrator/Cargo.toml index 1b7be7961..31fed5187 100644 --- a/packages/migrator/Cargo.toml +++ b/packages/migrator/Cargo.toml @@ -13,8 +13,12 @@ path = "src/lib.rs" [dependencies] async-trait = "0.1.88" +automerge = "0.8.0" clap = { version = "4.5.44", features = ["derive"] } dotenvy = "0.15.7" +notebook-types = { version = "0.1.0", path = "../notebook-types", features = ["backend"] } +samod = { version = "0.9.0", features = ["tokio"] } +serde_json = "1.0" sqlx = { version = "0.8.2", features = [ "runtime-tokio", "tls-rustls", @@ -27,3 +31,4 @@ sqlx_migrator = { version = "0.17.0", features = ["postgres"] } tokio = { version = "1.40.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +uuid = "1" diff --git a/packages/migrator/src/lib.rs b/packages/migrator/src/lib.rs index 51ef2c427..49c087dbe 100644 --- a/packages/migrator/src/lib.rs +++ b/packages/migrator/src/lib.rs @@ -1,3 +1,6 @@ mod migrations; +/// Storage adapters for Automerge. +pub mod storage; + pub use migrations::migrations; diff --git a/packages/migrator/src/main.rs b/packages/migrator/src/main.rs index a28a8b144..4d63b7787 100644 --- a/packages/migrator/src/main.rs +++ b/packages/migrator/src/main.rs @@ -3,15 +3,13 @@ use sqlx_migrator::Info; use sqlx_migrator::cli::MigrationCommand; use sqlx_migrator::migrator::Migrator; -mod migrations; - #[tokio::main] async fn main() { let database_url = dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` should be set"); let mut migrator = Migrator::default(); migrator - .add_migrations(migrations::migrations()) + .add_migrations(migrator::migrations()) .expect("Failed to load migrations"); let mut conn = PgConnection::connect(&database_url) diff --git a/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs b/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs new file mode 100644 index 000000000..b4a4dc70b --- /dev/null +++ b/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs @@ -0,0 +1,351 @@ +use sqlx::postgres::PgConnectOptions; +use sqlx::{Acquire, PgConnection, PgPool, Postgres, Row}; +use sqlx_migrator::Migration; +use sqlx_migrator::Operation; +use sqlx_migrator::error::Error; +use sqlx_migrator::vec_box; +use uuid::Uuid; + +use crate::storage::PostgresStorage; + +pub(crate) struct UndoRedoSnapshots; + +#[async_trait::async_trait] +impl Migration for UndoRedoSnapshots { + fn app(&self) -> &str { + "backend" + } + + fn name(&self) -> &str { + "m20260330000000_undo_redo_snapshots" + } + + fn parents(&self) -> Vec>> { + vec![] + } + + fn operations(&self) -> Vec>> { + vec_box![ + MoveDocIdToRefs, + AddHeadsToSnapshots, + PopulateHeads, + DropDocIdFromSnapshots, + RenameHeadAndDropGetRefStubs + ] + } + + fn is_atomic(&self) -> bool { + false + } +} + +/// Step 1: Add `doc_id` column to `refs`, populated from each ref's head snapshot. +struct MoveDocIdToRefs; + +#[async_trait::async_trait] +impl Operation for MoveDocIdToRefs { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + sqlx::query("ALTER TABLE refs ADD COLUMN doc_id TEXT").execute(&mut *tx).await?; + + sqlx::query( + "UPDATE refs SET doc_id = (SELECT doc_id FROM snapshots WHERE snapshots.id = refs.head)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query("ALTER TABLE refs ALTER COLUMN doc_id SET NOT NULL") + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE refs DROP COLUMN IF EXISTS doc_id") + .execute(conn) + .await?; + Ok(()) + } +} + +/// Step 2: Add `heads` column to `snapshots` (nullable initially). +struct AddHeadsToSnapshots; + +#[async_trait::async_trait] +impl Operation for AddHeadsToSnapshots { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots ADD COLUMN heads BYTEA[]") + .execute(conn) + .await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots DROP COLUMN IF EXISTS heads") + .execute(conn) + .await?; + Ok(()) + } +} + +/// Step 3: Populate `heads` for each snapshot. +/// +/// For each snapshot, try to load the Automerge document from samod using +/// the snapshot's `doc_id`. If found, extract its heads. Otherwise, create a +/// new Automerge document from the snapshot's JSON content and use its heads. +struct PopulateHeads; + +#[async_trait::async_trait] +impl Operation for PopulateHeads { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + let pool = pool_for_current_database(conn).await?; + + let repo: samod::Repo = samod::Repo::build_tokio() + .with_storage(PostgresStorage::new(pool.clone())) + .load() + .await; + + // Fetch all snapshots that need heads populated. + let rows = + sqlx::query("SELECT id, for_ref, doc_id, content FROM snapshots WHERE heads IS NULL") + .fetch_all(&pool) + .await?; + + for row in &rows { + let snapshot_id: i32 = row.get("id"); + let ref_id: Uuid = row.get("for_ref"); + let doc_id_str: &str = row.get("doc_id"); + let content: serde_json::Value = row.get("content"); + + // Try to load the Automerge document via samod. + let heads = match load_heads_via_samod(&repo, doc_id_str).await { + Some(heads) => heads, + None => { + // Fallback: create an Automerge doc from JSON content + // and add it to the repo so it's persisted in storage. + // Update refs.doc_id to point to the newly created document. + let (heads, new_doc_id) = create_doc_in_repo(&repo, &content).await?; + + sqlx::query("UPDATE refs SET doc_id = $1 WHERE id = $2") + .bind(&new_doc_id) + .bind(ref_id) + .execute(&pool) + .await?; + + heads + } + }; + + let heads_bytes: Vec> = heads.iter().map(|h| h.0.to_vec()).collect(); + + sqlx::query("UPDATE snapshots SET heads = $1 WHERE id = $2") + .bind(&heads_bytes) + .bind(snapshot_id) + .execute(&pool) + .await?; + } + + repo.stop().await; + + // Now make the column NOT NULL. + sqlx::query("ALTER TABLE snapshots ALTER COLUMN heads SET NOT NULL") + .execute(&pool) + .await?; + + pool.close().await; + + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + // Make heads nullable again (the column itself is dropped in AddHeadsToSnapshots::down). + sqlx::query("ALTER TABLE snapshots ALTER COLUMN heads DROP NOT NULL") + .execute(conn) + .await?; + Ok(()) + } +} + +/// Create a `PgPool` connected to the same database as the given connection. +/// +/// The migrator passes a `PgConnection` but samod needs a `PgPool`. We derive +/// the pool's connect options from `DATABASE_URL` (for server coordinates) and +/// the connection's `current_database()` (for the actual database name). This +/// ensures the pool connects to the correct database even in test environments +/// where `#[sqlx::test]` creates temporary databases. +async fn pool_for_current_database(conn: &mut PgConnection) -> Result { + let db_name: String = + sqlx::query_scalar("SELECT current_database()").fetch_one(&mut *conn).await?; + + let base_url = dotenvy::var("DATABASE_URL").map_err(|e| { + let err: Box = + format!("DATABASE_URL not set: {e}").into(); + err + })?; + + let opts: PgConnectOptions = base_url.parse().map_err(|e: sqlx::Error| { + let err: Box = Box::new(e); + err + })?; + let opts = opts.database(&db_name); + + let pool = PgPool::connect_with(opts).await?; + Ok(pool) +} + +/// Load Automerge heads via samod by finding the document by its ID. +async fn load_heads_via_samod( + repo: &samod::Repo, + doc_id_str: &str, +) -> Option> { + let doc_id: samod::DocumentId = doc_id_str.parse().ok()?; + let doc_handle = repo.find(doc_id).await.ok()??; + Some(doc_handle.with_document(|doc| doc.get_heads().to_vec())) +} + +/// Create an Automerge document from JSON content, add it to the repo, and +/// return its heads and new document ID. +async fn create_doc_in_repo( + repo: &samod::Repo, + content: &serde_json::Value, +) -> Result<(Vec, String), Error> { + use notebook_types::automerge_json::populate_automerge_from_json; + + let mut doc = automerge::Automerge::new(); + doc.transact(|tx| { + populate_automerge_from_json(tx, automerge::ROOT, content)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .map_err(|e| -> Error { + Box::::from(format!( + "Failed to create automerge doc from JSON: {:?}", + e + )) + .into() + })?; + + let heads = doc.get_heads().to_vec(); + + let doc_handle = repo.create(doc).await.map_err(|e| { + let err: Box = Box::new(e); + Error::Box(err) + })?; + + let doc_id = doc_handle.document_id().to_string(); + + Ok((heads, doc_id)) +} + +/// Step 4: Drop `doc_id` from `snapshots`. +struct DropDocIdFromSnapshots; + +#[async_trait::async_trait] +impl Operation for DropDocIdFromSnapshots { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots DROP COLUMN doc_id").execute(conn).await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + sqlx::query("ALTER TABLE snapshots ADD COLUMN doc_id TEXT") + .execute(&mut *tx) + .await?; + + // Repopulate from refs.doc_id. + sqlx::query( + "UPDATE snapshots SET doc_id = (SELECT doc_id FROM refs WHERE refs.id = snapshots.for_ref)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query("ALTER TABLE snapshots ALTER COLUMN doc_id SET NOT NULL") + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } +} + +/// Step 5: Rename `refs.head` to `refs.current_snapshot` and drop unused `get_ref_stubs`. +struct RenameHeadAndDropGetRefStubs; + +#[async_trait::async_trait] +impl Operation for RenameHeadAndDropGetRefStubs { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + // this function is no longer used since the last migration + sqlx::query("DROP FUNCTION IF EXISTS get_ref_stubs(text, uuid[])") + .execute(&mut *tx) + .await?; + + sqlx::query("ALTER TABLE refs RENAME COLUMN head TO current_snapshot") + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + sqlx::query("ALTER TABLE refs RENAME COLUMN current_snapshot TO head") + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + CREATE OR REPLACE FUNCTION get_ref_stubs( + in_searcher_id TEXT, + in_ref_ids UUID[] + ) + RETURNS TABLE ( + ref_id uuid, + name text, + type_name text, + created_at timestamptz, + permission_level permission_level, + owner_id text, + owner_username text, + owner_display_name text + ) + LANGUAGE SQL + STABLE + AS $$ + SELECT + refs.id AS ref_id, + snapshots.content->>'name' AS name, + snapshots.content->>'type' AS type_name, + refs.created AS created_at, + get_max_permission(in_searcher_id, refs.id) AS permission_level, + owner.id AS owner_id, + owner.username AS owner_username, + owner.display_name AS owner_display_name + FROM + unnest(in_ref_ids) WITH ORDINALITY AS unnested_ref_ids(ref_id, ord) + JOIN refs ON refs.id = unnested_ref_ids.ref_id + JOIN snapshots ON snapshots.id = refs.head + JOIN permissions p_owner + ON p_owner.object = refs.id + AND p_owner.level = 'own' + LEFT JOIN users owner + ON owner.id = p_owner.subject + ORDER BY + unnested_ref_ids.ord + $$; + "#, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } +} diff --git a/packages/migrator/src/migrations/mod.rs b/packages/migrator/src/migrations/mod.rs index 630c95662..91a63dd7c 100644 --- a/packages/migrator/src/migrations/mod.rs +++ b/packages/migrator/src/migrations/mod.rs @@ -11,6 +11,7 @@ mod m20250924133640_add_refs_deleted_at; mod m20251006141026_get_ref_stubs; mod m20260124120000_add_user_fk_cascade; mod m20260320000000_add_user_state_doc_id; +mod m20260330000000_undo_redo_snapshots; pub fn migrations() -> Vec>> { vec_box![ @@ -23,5 +24,6 @@ pub fn migrations() -> Vec>> { m20250924133640_add_refs_deleted_at::AddRefsDeletedAt, m20260124120000_add_user_fk_cascade::AddUserFkCascade, m20260320000000_add_user_state_doc_id::AddUserStateDocId, + m20260330000000_undo_redo_snapshots::UndoRedoSnapshots, ] } diff --git a/packages/migrator/src/storage/mod.rs b/packages/migrator/src/storage/mod.rs new file mode 100644 index 000000000..9c7e0292b --- /dev/null +++ b/packages/migrator/src/storage/mod.rs @@ -0,0 +1,5 @@ +//! Storage adapters for Automerge. + +mod postgres; + +pub use postgres::PostgresStorage; diff --git a/packages/backend/src/storage/postgres.rs b/packages/migrator/src/storage/postgres.rs similarity index 100% rename from packages/backend/src/storage/postgres.rs rename to packages/migrator/src/storage/postgres.rs diff --git a/packages/backend/src/automerge_json.rs b/packages/notebook-types/src/automerge_json.rs similarity index 80% rename from packages/backend/src/automerge_json.rs rename to packages/notebook-types/src/automerge_json.rs index c026b228a..65e08b9bd 100644 --- a/packages/backend/src/automerge_json.rs +++ b/packages/notebook-types/src/automerge_json.rs @@ -1,13 +1,8 @@ //! Utilities for converting between JSON values and Automerge documents. -use crate::app::AppState; -use crate::document::{RefContent, autosave}; use automerge::hydrate; use automerge::transaction::Transactable; -use futures_util::stream::StreamExt; -use samod::DocHandle; use serde_json::Value; -use uuid::Uuid; /// Insert a JSON value into a map property. fn insert_value_into_map<'a>( @@ -165,38 +160,3 @@ fn scalar_to_json(s: &automerge::ScalarValue) -> Value { ])), } } - -/// Spawns a background task that listens for document changes and triggers autosave. -pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: DocHandle) { - let listeners = state.active_listeners.read().await; - if listeners.contains(&ref_id) { - return; - } - - // Explicitly drop the read lock before acquiring write lock - drop(listeners); - - let mut listeners = state.active_listeners.write().await; - listeners.insert(ref_id); - - tokio::spawn({ - let state = state.clone(); - async move { - let mut changes = doc_handle.changes(); - - while (changes.next().await).is_some() { - let cloned_doc = doc_handle.with_document(|doc| doc.clone()); - let hydrated = cloned_doc.hydrate(None); - let content = hydrate_to_json(&hydrated); - - let data = RefContent { ref_id, content }; - if let Err(e) = autosave(state.clone(), data).await { - tracing::error!("Autosave failed for ref {}: {:?}", ref_id, e); - } - } - - state.active_listeners.write().await.remove(&ref_id); - tracing::error!("Autosave listener stopped for ref {}", ref_id); - } - }); -} diff --git a/packages/notebook-types/src/lib.rs b/packages/notebook-types/src/lib.rs index 98b30f3ce..83ff74c14 100644 --- a/packages/notebook-types/src/lib.rs +++ b/packages/notebook-types/src/lib.rs @@ -6,6 +6,9 @@ use wasm_bindgen::prelude::*; mod v0; pub mod v1; +#[cfg(feature = "backend")] +pub mod automerge_json; + #[cfg(test)] mod test_utils; From 209825949d9118521ce6fdfbd53c85c2458c9728 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 14:41:48 +0100 Subject: [PATCH 02/38] ENH: Autosave snapshot after 500ms settling time --- packages/backend/src/autosave.rs | 29 ++++--- packages/backend/src/document.rs | 89 +++++++--------------- packages/backend/src/rpc.rs | 10 ++- packages/backend/tests/user_state_tests.rs | 62 +++++++++++---- 4 files changed, 101 insertions(+), 89 deletions(-) diff --git a/packages/backend/src/autosave.rs b/packages/backend/src/autosave.rs index 2ed31a073..23ce53129 100644 --- a/packages/backend/src/autosave.rs +++ b/packages/backend/src/autosave.rs @@ -1,12 +1,15 @@ //! Autosave listener that persists Automerge document changes to the database. +use std::time::Duration; + use crate::app::AppState; -use crate::document::{RefContent, autosave}; +use crate::document::create_snapshot; use futures_util::stream::StreamExt; -use notebook_types::automerge_json::hydrate_to_json; use samod::DocHandle; use uuid::Uuid; +const SNAPSHOT_DEBOUNCE_MS: u64 = 500; + /// Spawns a background task that listens for document changes and triggers autosave. pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: DocHandle) { let listeners = state.active_listeners.read().await; @@ -24,19 +27,21 @@ pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: let state = state.clone(); async move { let mut changes = doc_handle.changes(); + let mut snapshot_handle: Option> = None; while (changes.next().await).is_some() { - let (hydrated, heads) = doc_handle.with_document(|doc| { - let heads: Vec> = - doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); - (doc.hydrate(None), heads) - }); - let content = hydrate_to_json(&hydrated); - - let data = RefContent { ref_id, content, heads }; - if let Err(e) = autosave(state.clone(), data).await { - tracing::error!("Autosave failed for ref {}: {:?}", ref_id, e); + if let Some(handle) = snapshot_handle.take() { + handle.abort(); } + snapshot_handle = Some(tokio::spawn({ + let state = state.clone(); + async move { + tokio::time::sleep(Duration::from_millis(SNAPSHOT_DEBOUNCE_MS)).await; + if let Err(e) = create_snapshot(state, ref_id).await { + tracing::error!("Snapshot failed for ref {}: {:?}", ref_id, e); + } + } + })); } state.active_listeners.write().await.remove(&ref_id); diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index e9ac487fc..8d210aa93 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -2,11 +2,10 @@ use crate::app::{AppCtx, AppError, AppState}; use crate::autosave::ensure_autosave_listener; -use notebook_types::automerge_json::populate_automerge_from_json; +use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; use samod::DocumentId; -use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -144,50 +143,38 @@ pub async fn ref_deleted_at( Ok(query.fetch_one(&state.db).await?.deleted_at) } -/// Saves the document by overwriting the snapshot at the current head, -/// then updates user state for all affected users. -pub async fn autosave(state: AppState, data: RefContent) -> Result<(), AppError> { - let RefContent { ref_id, content, heads } = data; - sqlx::query!( - " - UPDATE snapshots - SET content = $2, last_updated = NOW(), heads = $3 - WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) - ", - ref_id, - content, - &heads, - ) - .execute(&state.db) - .await?; - - if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { - tracing::error!(%ref_id, error = %e, "Failed to update user states after autosave"); - } - - Ok(()) -} - /// Saves the document by replacing the head with a new snapshot. /// /// The snapshot at the previous head is *not* deleted. pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppError> { - let doc_id = get_doc_id(state.clone(), ref_id).await?; + let query = sqlx::query!( + " + SELECT doc_id FROM refs WHERE id = $1 + ", + ref_id + ); - let doc_handle = state - .repo - .find(doc_id) - .await? - .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; + let doc_id = query.fetch_one(&state.db).await?.doc_id; + let doc_id: samod::DocumentId = doc_id + .parse() + .map_err(|_| AppError::Invalid("Invalid document ID".to_string()))?; - let (cloned_doc, heads) = doc_handle.with_document(|doc| { - let heads: Vec> = doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); - (doc.clone(), heads) - }); + let (cloned_doc, heads, doc_content) = { + let doc_handle = state + .repo + .find(doc_id) + .await? + .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; + + doc_handle.with_document(|doc| { + let heads: Vec> = doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); + let hydrated = doc.hydrate(None); + let doc_content = hydrate_to_json(&hydrated); + (doc.clone(), heads, doc_content) + }) + }; let cloned_handle = state.repo.create(cloned_doc).await?; - let doc_content = head_snapshot(state.clone(), ref_id).await?; - sqlx::query!( " WITH snapshot AS ( @@ -207,6 +194,10 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr .execute(&state.db) .await?; + if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { + tracing::error!(%ref_id, error = %e, "Failed to update user states after create_snapshot"); + } + Ok(()) } @@ -264,27 +255,5 @@ pub async fn get_doc_id(state: AppState, ref_id: Uuid) -> Result>, -} diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index d05f0291d..a11c1fa9b 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -7,6 +7,7 @@ use tracing::debug; use uuid::Uuid; use super::app::{AppCtx, AppError, AppState}; +use super::autosave::ensure_autosave_listener; use super::auth::{NewPermissions, PermissionLevel, Permissions}; use super::user_state::get_or_create_user_state_doc; use super::{auth, document as doc, user}; @@ -45,7 +46,14 @@ async fn get_doc(ctx: AppCtx, ref_id: Uuid) -> RpcResult { let is_deleted = deleted_at.is_some(); if max_level >= Some(PermissionLevel::Write) { - let doc_id = doc::get_doc_id(ctx.state, ref_id).await?; + let doc_id = doc::get_doc_id(ctx.state.clone(), ref_id).await?; + let doc_handle = ctx + .state + .repo + .find(doc_id.clone()) + .await? + .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; + ensure_autosave_listener(ctx.state.clone(), ref_id, doc_handle).await; Ok(RefDoc::Live { doc_id: doc_id.to_string(), is_deleted, diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index 6d1f489a1..93617e255 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -426,12 +426,22 @@ mod integration_tests { assert_eq!(before.documents[&ref_id.to_string()].name.as_str(), "Original Name"); let updated = create_test_document_content("Updated Name"); - document::autosave( - state.clone(), - backend::document::RefContent { ref_id, content: updated, heads: vec![] }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query!( + r#" + UPDATE snapshots + SET content = $2, last_updated = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, + ref_id, + updated, + &fake_heads, ) - .await - .expect("Failed to autosave"); + .execute(&pool) + .await?; + backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) + .await + .expect("Failed to propagate user state update"); let after = read_user_state_from_samod(&state, &user_id).await.unwrap(); assert_eq!(after.documents[&ref_id.to_string()].name.as_str(), "Updated Name"); @@ -475,14 +485,24 @@ mod integration_tests { assert_eq!(s.documents[&diagram_id.to_string()].type_name, DocumentType::Diagram); assert_eq!(s.documents[&diagram_id.to_string()].theory, None); - // Update theory via autosave + // Update theory and propagate user state let updated = create_model_document_content("Theory Test", "petri-net"); - document::autosave( - state.clone(), - backend::document::RefContent { ref_id, content: updated, heads: vec![] }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query!( + r#" + UPDATE snapshots + SET content = $2, last_updated = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, + ref_id, + updated, + &fake_heads, ) - .await - .expect("Failed to autosave"); + .execute(&pool) + .await?; + backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) + .await + .expect("Failed to propagate user state update"); let s = read_user_state_from_samod(&state, &user_id).await.unwrap(); assert_eq!(s.documents[&ref_id.to_string()].theory.as_deref(), Some("petri-net")); @@ -527,12 +547,22 @@ mod integration_tests { .expect("Failed to initialize owner2 state"); let updated = create_test_document_content("Updated by Autosave"); - document::autosave( - state.clone(), - backend::document::RefContent { ref_id, content: updated, heads: vec![] }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query!( + r#" + UPDATE snapshots + SET content = $2, last_updated = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, + ref_id, + updated, + &fake_heads, ) - .await - .expect("Failed to autosave"); + .execute(&pool) + .await?; + backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) + .await + .expect("Failed to propagate user state update"); let s1 = read_user_state_from_samod(&state, &owner1_id).await.unwrap(); let s2 = read_user_state_from_samod(&state, &owner2_id).await.unwrap(); From abc8d865410bca8327c93f5788df14851662f350 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 14:50:28 +0100 Subject: [PATCH 03/38] TEST: Add autosave_tests --- packages/backend/tests/autosave_tests.rs | 85 +++++++++++++++++++++ packages/backend/tests/common/mod.rs | 1 + packages/backend/tests/common/test_utils.rs | 83 ++++++++++++++++++++ packages/backend/tests/user_state_tests.rs | 82 ++------------------ 4 files changed, 177 insertions(+), 74 deletions(-) create mode 100644 packages/backend/tests/autosave_tests.rs create mode 100644 packages/backend/tests/common/mod.rs create mode 100644 packages/backend/tests/common/test_utils.rs diff --git a/packages/backend/tests/autosave_tests.rs b/packages/backend/tests/autosave_tests.rs new file mode 100644 index 000000000..5dbad24ce --- /dev/null +++ b/packages/backend/tests/autosave_tests.rs @@ -0,0 +1,85 @@ +//! Integration tests for autosave behavior. +//! +//! These tests require a running PostgreSQL database and exercise the +//! autosave listener end-to-end. +#[cfg(feature = "integration-tests")] +mod common; + +#[cfg(feature = "integration-tests")] +mod integration_tests { + use crate::common::test_utils::{ + create_test_app_state, create_test_document_content, create_test_firebase_user, + ensure_user_exists, run_migrations, + }; + use automerge::transaction::Transactable; + use backend::app::AppCtx; + use backend::document; + use sqlx::PgPool; + use uuid::Uuid; + + /// A document change should create a new snapshot after debounce. + #[sqlx::test] + async fn autosave_creates_snapshot_after_settle(pool: PgPool) -> sqlx::Result<()> { + run_migrations(&pool).await?; + let state = create_test_app_state(pool.clone()).await; + + let user_id = format!("test_user_{}", Uuid::now_v7()); + ensure_user_exists(&pool, &user_id).await.expect("Failed to create user"); + + let ctx = AppCtx { + state: state.clone(), + user: Some(create_test_firebase_user(&user_id)), + }; + let content = create_test_document_content("Autosave Base"); + let ref_id = document::new_ref(ctx, content).await.expect("Failed to create ref"); + + let before_snapshot_id: i32 = + sqlx::query_scalar("SELECT current_snapshot FROM refs WHERE id = $1") + .bind(ref_id) + .fetch_one(&pool) + .await?; + + let doc_id = document::get_doc_id(state.clone(), ref_id) + .await + .expect("Failed to read doc id"); + let doc_handle = state + .repo + .find(doc_id) + .await + .expect("Repo lookup failed") + .expect("Document not found in repo"); + + doc_handle + .with_document(|doc| { + doc.transact(|tx| { + tx.put(automerge::ROOT, "name", "Autosaved Name")?; + Ok::<_, automerge::AutomergeError>(()) + }) + .map(|_| ()) + }) + .expect("Failed to mutate document"); + + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + + let after_snapshot_id: i32 = + sqlx::query_scalar("SELECT current_snapshot FROM refs WHERE id = $1") + .bind(ref_id) + .fetch_one(&pool) + .await?; + + assert_ne!( + before_snapshot_id, after_snapshot_id, + "expected debounced autosave to create a new snapshot" + ); + + let snapshot_name: Option = + sqlx::query_scalar("SELECT content->>'name' FROM snapshots WHERE id = $1") + .bind(after_snapshot_id) + .fetch_one(&pool) + .await?; + + assert_eq!(snapshot_name.as_deref(), Some("Autosaved Name")); + + Ok(()) + } +} diff --git a/packages/backend/tests/common/mod.rs b/packages/backend/tests/common/mod.rs new file mode 100644 index 000000000..681d26e34 --- /dev/null +++ b/packages/backend/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod test_utils; diff --git a/packages/backend/tests/common/test_utils.rs b/packages/backend/tests/common/test_utils.rs new file mode 100644 index 000000000..a53067c0d --- /dev/null +++ b/packages/backend/tests/common/test_utils.rs @@ -0,0 +1,83 @@ +use backend::app::{AppError, AppState}; +use firebase_auth::FirebaseUser; +use serde_json::json; +use sqlx::PgPool; +use sqlx_migrator::migrator::{Migrate, Migrator}; +use sqlx_migrator::{Info, Plan}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::Error> { + let mut conn = pool.acquire().await?; + let mut migrator = Migrator::::default(); + migrator + .add_migrations(migrator::migrations()) + .expect("Failed to load migrations"); + + let plan = Plan::apply_all(); + migrator.run(&mut *conn, &plan).await.expect("Failed to run migrations"); + Ok(()) +} + +pub async fn ensure_user_exists(pool: &PgPool, user_id: &str) -> Result<(), AppError> { + sqlx::query!( + r#" + INSERT INTO users (id, created, signed_in) + VALUES ($1, NOW(), NOW()) + ON CONFLICT (id) DO NOTHING + "#, + user_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn create_test_app_state(pool: PgPool) -> AppState { + let storage = backend::storage::PostgresStorage::new(pool.clone()); + let repo = samod::Repo::builder(tokio::runtime::Handle::current()) + .with_storage(storage) + .with_announce_policy(|_doc_id, _peer_id| false) + .load() + .await; + + AppState { + db: pool, + repo, + active_listeners: Arc::new(RwLock::new(HashSet::new())), + initialized_user_states: Arc::new(RwLock::new(HashMap::new())), + http_client: reqwest::Client::new(), + julia_url: None, + } +} + +pub fn create_test_firebase_user(user_id: &str) -> FirebaseUser { + serde_json::from_value(json!({ + "iss": "test", + "aud": "test", + "sub": user_id, + "iat": 0, + "exp": u64::MAX, + "auth_time": 0, + "user_id": user_id, + "firebase": { + "sign_in_provider": "test", + "identities": {} + } + })) + .expect("Failed to create test FirebaseUser") +} + +pub fn create_test_document_content(name: &str) -> serde_json::Value { + json!({ + "version": "1", + "type": "model", + "name": name, + "theory": "test-theory", + "notebook": { + "cellOrder": [], + "cellContents": {} + } + }) +} diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index 93617e255..c8655bda0 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -4,69 +4,24 @@ //! RPC-level user state update path (document CRUD, permission changes, //! profile updates → Automerge user state doc). #[cfg(feature = "integration-tests")] -mod integration_tests { +mod common; - use backend::app::AppError; +#[cfg(feature = "integration-tests")] +mod integration_tests { + use crate::common::test_utils::{ + create_test_app_state, create_test_document_content, create_test_firebase_user, + ensure_user_exists, run_migrations, + }; use sqlx::PgPool; - use sqlx_migrator::migrator::{Migrate, Migrator}; - use sqlx_migrator::{Info, Plan}; use uuid::Uuid; - /// Run migrations on a test database pool. - async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::Error> { - let mut conn = pool.acquire().await?; - let mut migrator = Migrator::::default(); - migrator - .add_migrations(migrator::migrations()) - .expect("Failed to load migrations"); - - let plan = Plan::apply_all(); - migrator.run(&mut *conn, &plan).await.expect("Failed to run migrations"); - Ok(()) - } - - pub async fn ensure_user_exists(pool: &PgPool, user_id: &str) -> Result<(), AppError> { - sqlx::query!( - r#" - INSERT INTO users (id, created, signed_in) - VALUES ($1, NOW(), NOW()) - ON CONFLICT (id) DO NOTHING - "#, - user_id - ) - .execute(pool) - .await?; - Ok(()) - } - use autosurgeon::hydrate; use backend::app::{AppCtx, AppState}; use backend::auth::{NewPermissions, PermissionLevel}; use backend::document; use backend::user_state::{DocumentType, UserState}; - use firebase_auth::FirebaseUser; use serde_json::json; - use std::collections::{HashMap, HashSet}; - use std::sync::Arc; - use tokio::sync::RwLock; - - async fn create_test_app_state(pool: PgPool) -> AppState { - let storage = backend::storage::PostgresStorage::new(pool.clone()); - let repo = samod::Repo::builder(tokio::runtime::Handle::current()) - .with_storage(storage) - .with_announce_policy(|_doc_id, _peer_id| false) - .load() - .await; - - AppState { - db: pool, - repo, - active_listeners: Arc::new(RwLock::new(HashSet::new())), - initialized_user_states: Arc::new(RwLock::new(HashMap::new())), - http_client: reqwest::Client::new(), - julia_url: None, - } - } + use std::collections::HashMap; /// Helper to read user state from samod using the stored document ID. async fn read_user_state_from_samod(state: &AppState, user_id: &str) -> Option { @@ -81,27 +36,6 @@ mod integration_tests { doc_handle.with_document(|doc| hydrate(doc)).ok() } - fn create_test_firebase_user(user_id: &str) -> FirebaseUser { - serde_json::from_value(json!({ - "iss": "test", - "aud": "test", - "sub": user_id, - "iat": 0, - "exp": u64::MAX, - "auth_time": 0, - "user_id": user_id, - "firebase": { - "sign_in_provider": "test", - "identities": {} - } - })) - .expect("Failed to create test FirebaseUser") - } - - fn create_test_document_content(name: &str) -> serde_json::Value { - create_model_document_content(name, "test-theory") - } - fn create_model_document_content(name: &str, theory: &str) -> serde_json::Value { json!({ "version": "1", From 4f4e5223bd222648dc364c01f12a75340b61f31c Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 15:40:42 +0100 Subject: [PATCH 04/38] REFACTOR: last_updated -> created_at --- packages/backend/schema.svg | 226 ++++++++++-------- packages/backend/src/document.rs | 30 +-- packages/backend/tests/migration_tests.rs | 45 ++++ packages/backend/tests/user_state_tests.rs | 44 ++-- .../m20260330000000_undo_redo_snapshots.rs | 21 ++ 5 files changed, 223 insertions(+), 143 deletions(-) diff --git a/packages/backend/schema.svg b/packages/backend/schema.svg index 5ac6f2949..c5c693d06 100644 --- a/packages/backend/schema.svg +++ b/packages/backend/schema.svg @@ -4,147 +4,161 @@ - - + + 019b2816-7c3a-7048-8f0f-21c3c6ef817d - - -       permissions     - -level     - -permission_level        - -object     - -→ refs     - -subject     - -→ users     + + +       permissions     + +level     + +permission_level        + +object     + +→ refs     + +subject     + +→ users     019b281f-bedf-74d3-b933-15d4af5fa061 - - -    refs   - -id   - -uuid    - -created     - -timestamp      - -deleted_at      - -timestamp | null        - -head    - -→ snapshots       + + +    refs   + +id   + +uuid    + +created     + +timestamp      + +deleted_at      + +timestamp | null        + +doc_id     + +text    + +current_snapshot        + +→ snapshots       019b2816-7c3a-7048-8f0f-21c3c6ef817d:w->019b281f-bedf-74d3-b933-15d4af5fa061 - - + + 019b2829-bef7-7017-83c3-19f7ea771f00 - - -    users   - -id   - -uuid    - -created     - -timestamp      - -signed_in      - -timestamp | null        - -username      - -text | null       - -display_name       - -text | null       - -state_doc_id       - -text | null       + + +    users   + +id   + +uuid    + +created     + +timestamp      + +signed_in      + +timestamp | null        + +username      + +text | null       + +display_name       + +text | null       + +state_doc_id       + +text | null       019b2816-7c3a-7048-8f0f-21c3c6ef817d:w->019b2829-bef7-7017-83c3-19f7ea771f00 - - + + 019b282d-ec81-718f-af71-5e3d0f841924 - - -      snapshots    - -id   - -int    - -content     - -jsonb     - -last_updated       - -timestamp      - -doc_id     - -text    - -for_ref     - -→ refs     + + +      snapshots    + +id   + +int    + +content     + +jsonb     + +created_at      + +timestamp      + +heads     + +bytea[]     + +for_ref     + +→ refs     + +parent     + +→ snapshots       019b281f-bedf-74d3-b933-15d4af5fa061:w->019b282d-ec81-718f-af71-5e3d0f841924 - - + + 019b282d-ec81-718f-af71-5e3d0f841924:w->019b281f-bedf-74d3-b933-15d4af5fa061 - - + + + + + +019b282d-ec81-718f-af71-5e3d0f841924:w->019b282d-ec81-718f-af71-5e3d0f841924 + + 019b283f-3445-72bf-8102-98cb3c2617e8 - - -     storage    - -key    - -text[]     - -data    - -bytea     + + +     storage    + +key    + +text[]     + +data    + +bytea     diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 8d210aa93..4d62700e8 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -51,23 +51,23 @@ pub async fn new_ref(ctx: AppCtx, content: Value) -> Result { let mut txn = ctx.state.db.begin().await?; let user_id = ctx.user.map(|user| user.user_id); - sqlx::query!( + sqlx::query( " WITH snapshot AS ( - INSERT INTO snapshots(for_ref, content, last_updated, heads) + INSERT INTO snapshots(for_ref, content, created_at, heads) VALUES ($1, $2, NOW(), $4) - RETURNING id + RETURNING id ) INSERT INTO refs(id, current_snapshot, created, doc_id) VALUES ($1, (SELECT id FROM snapshot), NOW(), $3) ", - ref_id, - // Use the JSON provided by automerge as the authoritative content - // serde_json::to_value(doc_content), - content, - doc_id, - &heads, ) + .bind(ref_id) + // Use the JSON provided by automerge as the authoritative content + // serde_json::to_value(doc_content), + .bind(content) + .bind(doc_id) + .bind(&heads) .execute(&mut *txn) .await?; @@ -175,10 +175,10 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr }; let cloned_handle = state.repo.create(cloned_doc).await?; - sqlx::query!( + sqlx::query( " WITH snapshot AS ( - INSERT INTO snapshots(for_ref, content, last_updated, heads) + INSERT INTO snapshots(for_ref, content, created_at, heads) VALUES ($1, $2, NOW(), $4) RETURNING id ) @@ -186,11 +186,11 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr SET current_snapshot = (SELECT id FROM snapshot), doc_id = $3 WHERE id = $1 ", - ref_id, - doc_content, - cloned_handle.document_id().to_string(), - &heads, ) + .bind(ref_id) + .bind(doc_content) + .bind(cloned_handle.document_id().to_string()) + .bind(&heads) .execute(&state.db) .await?; diff --git a/packages/backend/tests/migration_tests.rs b/packages/backend/tests/migration_tests.rs index 82122f2bd..e4053fe5f 100644 --- a/packages/backend/tests/migration_tests.rs +++ b/packages/backend/tests/migration_tests.rs @@ -175,6 +175,32 @@ mod integration_tests { assert_eq!(count, 0); } + #[sqlx::test] + async fn test_migration_renames_last_updated_to_created_at(pool: PgPool) { + run_migrations(&pool).await; + + let created_at_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'created_at'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let last_updated_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'last_updated'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(created_at_count, 1, "created_at should exist after migration"); + assert_eq!(last_updated_count, 0, "last_updated should not exist after migration"); + } + // ── Data migration tests ── #[sqlx::test] @@ -346,6 +372,25 @@ mod integration_tests { .unwrap(); assert_eq!(count, 1, "doc_id should be restored on snapshots"); + // created_at should be reverted back to last_updated. + let created_at_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'created_at'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(created_at_count, 0, "created_at should be gone after rollback"); + + let last_updated_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'snapshots' AND column_name = 'last_updated'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_updated_count, 1, "last_updated should be restored after rollback"); + // doc_id on snapshots should be repopulated and non-empty. let snapshot_doc_id: String = sqlx::query_scalar( "SELECT doc_id FROM snapshots diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index c8655bda0..363372b9a 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -361,16 +361,16 @@ mod integration_tests { let updated = create_test_document_content("Updated Name"); let fake_heads: Vec> = vec![vec![0u8; 32]]; - sqlx::query!( + sqlx::query( r#" UPDATE snapshots - SET content = $2, last_updated = NOW(), heads = $3 + SET content = $2, created_at = NOW(), heads = $3 WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) "#, - ref_id, - updated, - &fake_heads, ) + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) .execute(&pool) .await?; backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) @@ -422,16 +422,16 @@ mod integration_tests { // Update theory and propagate user state let updated = create_model_document_content("Theory Test", "petri-net"); let fake_heads: Vec> = vec![vec![0u8; 32]]; - sqlx::query!( + sqlx::query( r#" UPDATE snapshots - SET content = $2, last_updated = NOW(), heads = $3 + SET content = $2, created_at = NOW(), heads = $3 WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) "#, - ref_id, - updated, - &fake_heads, ) + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) .execute(&pool) .await?; backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) @@ -482,16 +482,16 @@ mod integration_tests { let updated = create_test_document_content("Updated by Autosave"); let fake_heads: Vec> = vec![vec![0u8; 32]]; - sqlx::query!( + sqlx::query( r#" UPDATE snapshots - SET content = $2, last_updated = NOW(), heads = $3 + SET content = $2, created_at = NOW(), heads = $3 WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) "#, - ref_id, - updated, - &fake_heads, ) + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) .execute(&pool) .await?; backend::user_state_updates::update_ref_for_users(&state, ref_id, vec![]) @@ -984,10 +984,10 @@ mod integration_tests { // Use a fake heads value (32 zero bytes) for test data. let fake_heads: Vec> = vec![vec![0u8; 32]]; - sqlx::query!( + sqlx::query( r#" WITH snapshot AS ( - INSERT INTO snapshots (for_ref, content, last_updated, heads) + INSERT INTO snapshots (for_ref, content, created_at, heads) VALUES ($1, $2, $3, $5) RETURNING id ) @@ -995,12 +995,12 @@ mod integration_tests { VALUES ($1, (SELECT id FROM snapshot), $3, $4) ON CONFLICT (id) DO UPDATE SET current_snapshot = (SELECT id FROM snapshot) "#, - ref_id, - content, - doc.created_at, - format!("test_fake_automerge_doc_{ref_id}"), - &fake_heads, ) + .bind(ref_id) + .bind(content) + .bind(doc.created_at) + .bind(format!("test_fake_automerge_doc_{ref_id}")) + .bind(&fake_heads) .execute(db) .await?; diff --git a/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs b/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs index b4a4dc70b..82aab4d44 100644 --- a/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs +++ b/packages/migrator/src/migrations/m20260330000000_undo_redo_snapshots.rs @@ -28,6 +28,7 @@ impl Migration for UndoRedoSnapshots { vec_box![ MoveDocIdToRefs, AddHeadsToSnapshots, + RenameSnapshotTimestamp, PopulateHeads, DropDocIdFromSnapshots, RenameHeadAndDropGetRefStubs @@ -91,6 +92,26 @@ impl Operation for AddHeadsToSnapshots { } } +/// Step 2.5: Rename `snapshots.last_updated` to `snapshots.created_at`. +struct RenameSnapshotTimestamp; + +#[async_trait::async_trait] +impl Operation for RenameSnapshotTimestamp { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots RENAME COLUMN last_updated TO created_at") + .execute(conn) + .await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots RENAME COLUMN created_at TO last_updated") + .execute(conn) + .await?; + Ok(()) + } +} + /// Step 3: Populate `heads` for each snapshot. /// /// For each snapshot, try to load the Automerge document from samod using From 9b750fa720b559913ff21bade527bd9bd1ee8c33 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 15:55:47 +0100 Subject: [PATCH 05/38] ENH: Add snapshot info to user state --- packages/backend/src/user_state.rs | 44 +++++++++++++++-- packages/backend/tests/user_state_tests.rs | 55 ++++++++++++++++------ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/user_state.rs b/packages/backend/src/user_state.rs index 35b49be20..d6dacf71f 100644 --- a/packages/backend/src/user_state.rs +++ b/packages/backend/src/user_state.rs @@ -60,6 +60,22 @@ pub struct RelationInfo { pub relation_type: String, } +/// Lightweight snapshot metadata for user state synchronization. +#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))] +#[derive(Debug, Clone, Deserialize, Reconcile, Hydrate, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "user_state.ts")] +pub struct SnapshotInfo { + /// The database ID of this snapshot. + pub id: i32, + /// When this snapshot was created. + #[autosurgeon(rename = "createdAt", with = "datetime_millis")] + #[ts(type = "number")] + pub created_at: chrono::DateTime, + /// Automerge change hashes identifying this snapshot's document state, hex-encoded. + pub heads: Vec, +} + /// Document reference information for user state synchronization. /// /// Contains lightweight metadata about a document that the user has access to. @@ -85,6 +101,11 @@ pub struct DocInfo { #[autosurgeon(rename = "deletedAt", with = "option_datetime_millis")] #[ts(type = "number | null")] pub deleted_at: Option>, + /// The database ID of the current (active) snapshot. + #[autosurgeon(rename = "currentSnapshot")] + pub current_snapshot: i32, + /// All snapshots for this document, ordered by creation time. + pub snapshots: Vec, /// Outgoing relations from this document to other documents. #[autosurgeon(rename = "dependsOn")] pub depends_on: Vec, @@ -243,6 +264,7 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result Result>" + ) AS "permissions!: sqlx::types::Json>", + COALESCE( + (SELECT json_agg(json_build_object( + 'id', s.id, + 'createdAt', s.created_at, + 'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h) + ) ORDER BY s.id ASC) + FROM snapshots s + WHERE s.for_ref = refs.id + ), '[]'::json + ) AS "snapshots!: sqlx::types::Json>" FROM filtered_ids JOIN refs ON refs.id = filtered_ids.id JOIN snapshots ON snapshots.id = refs.current_snapshot @@ -282,6 +314,8 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result Result<(), AppError> { + ) -> Result { sqlx::query!( r#" INSERT INTO users (id, created, signed_in, username, display_name) @@ -953,6 +956,8 @@ mod integration_tests { .execute(db) .await?; + let mut snapshot_ids = HashMap::new(); + for (ref_id_str, doc) in &state.documents { let ref_id: Uuid = ref_id_str.parse().expect("Invalid UUID key"); @@ -982,9 +987,8 @@ mod integration_tests { content["theory"] = serde_json::Value::String(theory.clone()); } - // Use a fake heads value (32 zero bytes) for test data. let fake_heads: Vec> = vec![vec![0u8; 32]]; - sqlx::query( + let snapshot_id: i32 = sqlx::query_scalar( r#" WITH snapshot AS ( INSERT INTO snapshots (for_ref, content, created_at, heads) @@ -994,6 +998,7 @@ mod integration_tests { INSERT INTO refs (id, current_snapshot, created, doc_id) VALUES ($1, (SELECT id FROM snapshot), $3, $4) ON CONFLICT (id) DO UPDATE SET current_snapshot = (SELECT id FROM snapshot) + RETURNING current_snapshot "#, ) .bind(ref_id) @@ -1001,9 +1006,14 @@ mod integration_tests { .bind(doc.created_at) .bind(format!("test_fake_automerge_doc_{ref_id}")) .bind(&fake_heads) - .execute(db) + .fetch_one(db) .await?; + snapshot_ids.insert( + ref_id_str.clone(), + (snapshot_id, doc.created_at), + ); + if let Some(deleted_at) = doc.deleted_at { sqlx::query!( r#" @@ -1033,7 +1043,20 @@ mod integration_tests { } } - Ok(()) + let mut result = state.clone(); + let fake_heads_hex = + "0000000000000000000000000000000000000000000000000000000000000000".to_string(); + for (ref_id_str, doc) in result.documents.iter_mut() { + if let Some(&(sid, created_at)) = snapshot_ids.get(ref_id_str) { + doc.current_snapshot = sid; + doc.snapshots = vec![backend::user_state::SnapshotInfo { + id: sid, + created_at, + heads: vec![fake_heads_hex.clone()], + }]; + } + } + Ok(result) } /// Write→read roundtrip through the database. @@ -1044,9 +1067,10 @@ mod integration_tests { let (user_id, input_state) = user_id_and_state; let test_db = TestDb::new().await; - write_user_state_to_db(user_id.clone(), test_db.pool(), &input_state) - .await - .expect("Failed to write user state"); + let expected_state = + write_user_state_to_db(user_id.clone(), test_db.pool(), &input_state) + .await + .expect("Failed to write user state"); let output_state = read_user_state_from_db(user_id.clone(), test_db.pool()) .await @@ -1054,7 +1078,7 @@ mod integration_tests { test_db.cleanup().await; - proptest::prop_assert_eq!(input_state, output_state); + proptest::prop_assert_eq!(expected_state, output_state); } /// `get_or_create_user_state_doc` should initialize the Automerge doc @@ -1089,11 +1113,12 @@ mod integration_tests { julia_url: None, }; - write_user_state_to_db(user_id.clone(), test_db.pool(), &input_state) - .await - .expect("Failed to write user state"); + let expected_state = + write_user_state_to_db(user_id.clone(), test_db.pool(), &input_state) + .await + .expect("Failed to write user state"); - if !input_state.documents.is_empty() { + if !expected_state.documents.is_empty() { backend::user_state::get_or_create_user_state_doc(&state, &user_id) .await .expect("Failed to initialize user state"); @@ -1112,10 +1137,10 @@ mod integration_tests { test_db.cleanup().await; - if input_state.documents.is_empty() { + if expected_state.documents.is_empty() { proptest::prop_assert!(automerge_state.is_none()); } else { - proptest::prop_assert_eq!(Some(input_state), automerge_state); + proptest::prop_assert_eq!(Some(expected_state), automerge_state); } } } From 4eb7ea150f24bafdeff6255204a313a0d48001a1 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 16:18:06 +0100 Subject: [PATCH 06/38] ENH: Add set_current_snapshot RPC method --- packages/backend/src/document.rs | 31 +++++++++++++++++++++++++++++++ packages/backend/src/rpc.rs | 11 +++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 4d62700e8..d88b8eb2b 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -201,6 +201,37 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr Ok(()) } +/// Sets the current snapshot for a ref. +/// +/// The snapshot must belong to the given ref. The live Automerge document is +/// unchanged since it retains the full history; only the database pointer +/// is updated. +pub async fn set_current_snapshot( + state: AppState, + ref_id: Uuid, + snapshot_id: i32, +) -> Result<(), AppError> { + let rows_affected = sqlx::query!( + "UPDATE refs SET current_snapshot = $2 WHERE id = $1 + AND EXISTS (SELECT 1 FROM snapshots WHERE id = $2 AND for_ref = $1)", + ref_id, + snapshot_id, + ) + .execute(&state.db) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(AppError::Invalid("Snapshot not found for this ref".to_string())); + } + + if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { + tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); + } + + Ok(()) +} + /// Soft-deletes a document reference by setting `deleted_at`. pub async fn delete_ref(state: AppState, ref_id: Uuid) -> Result<(), AppError> { sqlx::query!( diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index a11c1fa9b..bff082429 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -19,6 +19,7 @@ pub fn router() -> Router { .handler(get_doc) .handler(head_snapshot) .handler(create_snapshot) + .handler(set_current_snapshot) .handler(delete_ref) .handler(restore_ref) .handler(get_permissions) @@ -114,6 +115,16 @@ async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { .into() } +#[handler(mutation)] +async fn set_current_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> RpcResult<()> { + async { + auth::authorize(&ctx, ref_id, PermissionLevel::Write).await?; + doc::set_current_snapshot(ctx.state, ref_id, snapshot_id).await + } + .await + .into() +} + #[handler(mutation)] async fn delete_ref(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async { From dbcaf1a5ccdbafce79245e6a11517a18a97d6942 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 16:22:00 +0100 Subject: [PATCH 07/38] TEST: Add frontend test for set_current_snapshot --- packages/frontend/src/api/user_state.test.ts | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/frontend/src/api/user_state.test.ts b/packages/frontend/src/api/user_state.test.ts index fe16a85b3..43b9db0e1 100644 --- a/packages/frontend/src/api/user_state.test.ts +++ b/packages/frontend/src/api/user_state.test.ts @@ -253,4 +253,70 @@ describe("User state Automerge document", async () => { assert(updatedDoc, "Document should still exist"); assert.strictEqual(updatedDoc.name, newName, "Name should be updated"); }); + + test("should switch current snapshot via set_current_snapshot", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Test Snapshot Switch - ${v4()}`; + const refId = await createDoc(name); + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should exist in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc, "Document should exist"); + assert.strictEqual(initialDoc.snapshots.length, 1, "Should start with one snapshot"); + const originalSnapshotId = initialDoc.snapshots[0]!.id; + assert.strictEqual( + initialDoc.currentSnapshot, + originalSnapshotId, + "currentSnapshot should equal the first snapshot id", + ); + + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live", "Document should be live"); + assert(isValidDocumentId(refDoc.docId)); + + const liveDocHandle: DocHandle<{ name: string }> = await repo.find(refDoc.docId); + await liveDocHandle.whenReady(); + + const newName = `Snapshot V2 - ${v4()}`; + liveDocHandle.change((doc) => { + doc.name = newName; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && doc.snapshots.length >= 2; + }, `Document ${refId} should have a second snapshot after autosave`); + + const afterAutosave = findDoc(refId); + assert(afterAutosave, "Document should exist after autosave"); + assert( + afterAutosave.currentSnapshot !== originalSnapshotId, + "currentSnapshot should have changed after autosave", + ); + assert.strictEqual(afterAutosave.snapshots.length, 2, "Should have two snapshots"); + + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && doc.currentSnapshot === originalSnapshotId; + }, `Document ${refId} should revert currentSnapshot to the original`); + + const reverted = findDoc(refId); + assert(reverted, "Document should still exist after revert"); + assert.strictEqual( + reverted.currentSnapshot, + originalSnapshotId, + "currentSnapshot should be back to the original", + ); + assert.strictEqual( + reverted.snapshots.length, + 2, + "Both snapshots should still be present", + ); + }); }); From 3aeaf86593b2522a179c956aeab6b13f11a44c37 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 16:37:18 +0100 Subject: [PATCH 08/38] ENH: Add parent to snapshots --- packages/backend/src/document.rs | 4 ++-- packages/backend/src/user_state.rs | 3 +++ packages/backend/tests/user_state_tests.rs | 1 + .../m20260330000000_undo_redo_snapshots.rs | 23 ++++++++++++++++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index d88b8eb2b..2f1a4f5ef 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -178,8 +178,8 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr sqlx::query( " WITH snapshot AS ( - INSERT INTO snapshots(for_ref, content, created_at, heads) - VALUES ($1, $2, NOW(), $4) + INSERT INTO snapshots(for_ref, content, created_at, heads, parent) + VALUES ($1, $2, NOW(), $4, (SELECT current_snapshot FROM refs WHERE id = $1)) RETURNING id ) UPDATE refs diff --git a/packages/backend/src/user_state.rs b/packages/backend/src/user_state.rs index d6dacf71f..55d83b8b1 100644 --- a/packages/backend/src/user_state.rs +++ b/packages/backend/src/user_state.rs @@ -68,6 +68,8 @@ pub struct RelationInfo { pub struct SnapshotInfo { /// The database ID of this snapshot. pub id: i32, + /// The parent snapshot this was derived from, or `None` for the root snapshot. + pub parent: Option, /// When this snapshot was created. #[autosurgeon(rename = "createdAt", with = "datetime_millis")] #[ts(type = "number")] @@ -277,6 +279,7 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result for UndoRedoSnapshots { RenameSnapshotTimestamp, PopulateHeads, DropDocIdFromSnapshots, - RenameHeadAndDropGetRefStubs + RenameHeadAndDropGetRefStubs, + AddParentToSnapshots ] } @@ -370,3 +371,23 @@ impl Operation for RenameHeadAndDropGetRefStubs { Ok(()) } } + +/// Step 6: Add nullable `parent` column to `snapshots` (FK to `snapshots.id`). +struct AddParentToSnapshots; + +#[async_trait::async_trait] +impl Operation for AddParentToSnapshots { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots ADD COLUMN parent INT REFERENCES snapshots(id)") + .execute(conn) + .await?; + Ok(()) + } + + async fn down(&self, conn: &mut PgConnection) -> Result<(), Error> { + sqlx::query("ALTER TABLE snapshots DROP COLUMN IF EXISTS parent") + .execute(conn) + .await?; + Ok(()) + } +} From bc1c2adcf211cff1d82852fb51205b0f54c720a4 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 16:51:04 +0100 Subject: [PATCH 09/38] ENH: Use a record/map for snapshots in user state --- packages/backend/src/user_state.rs | 32 +++++++++++--------- packages/backend/tests/user_state_tests.rs | 14 +++++---- packages/frontend/src/api/user_state.test.ts | 11 ++++--- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/user_state.rs b/packages/backend/src/user_state.rs index 55d83b8b1..419ec2dd3 100644 --- a/packages/backend/src/user_state.rs +++ b/packages/backend/src/user_state.rs @@ -66,8 +66,6 @@ pub struct RelationInfo { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "user_state.ts")] pub struct SnapshotInfo { - /// The database ID of this snapshot. - pub id: i32, /// The parent snapshot this was derived from, or `None` for the root snapshot. pub parent: Option, /// When this snapshot was created. @@ -106,8 +104,8 @@ pub struct DocInfo { /// The database ID of the current (active) snapshot. #[autosurgeon(rename = "currentSnapshot")] pub current_snapshot: i32, - /// All snapshots for this document, ordered by creation time. - pub snapshots: Vec, + /// All snapshots for this document, keyed by stringified snapshot ID. + pub snapshots: HashMap, /// Outgoing relations from this document to other documents. #[autosurgeon(rename = "dependsOn")] pub depends_on: Vec, @@ -277,16 +275,18 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result>", COALESCE( - (SELECT json_agg(json_build_object( - 'id', s.id, - 'parent', s.parent, - 'createdAt', s.created_at, - 'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h) - ) ORDER BY s.id ASC) + (SELECT json_object_agg( + s.id::text, + json_build_object( + 'parent', s.parent, + 'createdAt', s.created_at, + 'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h) + ) + ) FROM snapshots s WHERE s.for_ref = refs.id - ), '[]'::json - ) AS "snapshots!: sqlx::types::Json>" + ), '{}'::json + ) AS "snapshots!: sqlx::types::Json>" FROM filtered_ids JOIN refs ON refs.id = filtered_ids.id JOIN snapshots ON snapshots.id = refs.current_snapshot @@ -553,7 +553,9 @@ pub mod arbitrary { deleted_at: deleted_seconds .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")), current_snapshot: 1, - snapshots: Vec::new(), + snapshots: HashMap::new(), + // We are not yet generating complete relationship trees, just independent + // docs depends_on: Vec::new(), used_by: Vec::new(), } @@ -641,7 +643,9 @@ pub mod arbitrary { deleted_at: deleted_seconds .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")), current_snapshot: 1, - snapshots: Vec::new(), + snapshots: HashMap::new(), + // We are not yet generating complete relationship trees, just independent + // docs depends_on: Vec::new(), used_by: Vec::new(), }; diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index 1c3184a58..76ededd9b 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -1049,12 +1049,14 @@ mod integration_tests { for (ref_id_str, doc) in result.documents.iter_mut() { if let Some(&(sid, created_at)) = snapshot_ids.get(ref_id_str) { doc.current_snapshot = sid; - doc.snapshots = vec![backend::user_state::SnapshotInfo { - id: sid, - parent: None, - created_at, - heads: vec![fake_heads_hex.clone()], - }]; + doc.snapshots = HashMap::from([( + sid.to_string(), + backend::user_state::SnapshotInfo { + parent: None, + created_at, + heads: vec![fake_heads_hex.clone()], + }, + )]); } } Ok(result) diff --git a/packages/frontend/src/api/user_state.test.ts b/packages/frontend/src/api/user_state.test.ts index 43b9db0e1..deda21146 100644 --- a/packages/frontend/src/api/user_state.test.ts +++ b/packages/frontend/src/api/user_state.test.ts @@ -266,8 +266,9 @@ describe("User state Automerge document", async () => { const initialDoc = findDoc(refId); assert(initialDoc, "Document should exist"); - assert.strictEqual(initialDoc.snapshots.length, 1, "Should start with one snapshot"); - const originalSnapshotId = initialDoc.snapshots[0]!.id; + const snapshotIds = Object.keys(initialDoc.snapshots); + assert.strictEqual(snapshotIds.length, 1, "Should start with one snapshot"); + const originalSnapshotId = Number(snapshotIds[0]); assert.strictEqual( initialDoc.currentSnapshot, originalSnapshotId, @@ -288,7 +289,7 @@ describe("User state Automerge document", async () => { await waitFor(() => { const doc = findDoc(refId); - return doc !== undefined && doc.snapshots.length >= 2; + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; }, `Document ${refId} should have a second snapshot after autosave`); const afterAutosave = findDoc(refId); @@ -297,7 +298,7 @@ describe("User state Automerge document", async () => { afterAutosave.currentSnapshot !== originalSnapshotId, "currentSnapshot should have changed after autosave", ); - assert.strictEqual(afterAutosave.snapshots.length, 2, "Should have two snapshots"); + assert.strictEqual(Object.keys(afterAutosave.snapshots).length, 2, "Should have two snapshots"); unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); @@ -314,7 +315,7 @@ describe("User state Automerge document", async () => { "currentSnapshot should be back to the original", ); assert.strictEqual( - reverted.snapshots.length, + Object.keys(reverted.snapshots).length, 2, "Both snapshots should still be present", ); From db9f2950650d3ba09d6edc8a75cf1626c4213312 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 16:54:26 +0100 Subject: [PATCH 10/38] ENH: Add a history_navigator ui-component --- packages/frontend/src/user/document_list.tsx | 3 +- .../src/history_navigator.module.css | 81 ++++++++ .../src/history_navigator.stories.tsx | 194 ++++++++++++++++++ .../ui-components/src/history_navigator.tsx | 165 +++++++++++++++ packages/ui-components/src/index.ts | 2 + .../src}/virtual_list.ts | 0 6 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 packages/ui-components/src/history_navigator.module.css create mode 100644 packages/ui-components/src/history_navigator.stories.tsx create mode 100644 packages/ui-components/src/history_navigator.tsx rename packages/{frontend/src/util => ui-components/src}/virtual_list.ts (100%) diff --git a/packages/frontend/src/user/document_list.tsx b/packages/frontend/src/user/document_list.tsx index bb3cd6230..ef6464c73 100644 --- a/packages/frontend/src/user/document_list.tsx +++ b/packages/frontend/src/user/document_list.tsx @@ -5,9 +5,8 @@ import { useFirebaseApp } from "solid-firebase"; import { createMemo, createSignal, For, type JSX, Show, useContext } from "solid-js"; import { stringify as uuidStringify } from "uuid"; -import { DocumentTypeIcon } from "catcolab-ui-components"; +import { createVirtualList, DocumentTypeIcon } from "catcolab-ui-components"; import { TheoryLibraryContext } from "../theory"; -import { createVirtualList } from "../util/virtual_list"; import { currentUserPermission, formatOwners, useUserState } from "./user_state_context"; import "./documents.css"; diff --git a/packages/ui-components/src/history_navigator.module.css b/packages/ui-components/src/history_navigator.module.css new file mode 100644 index 000000000..71197aa97 --- /dev/null +++ b/packages/ui-components/src/history_navigator.module.css @@ -0,0 +1,81 @@ +.panel { + display: flex; + flex-direction: column; + height: 100%; + font-size: 1rem; + color: var(--color-foreground); + user-select: none; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + /* Match .row: left padding + .dotSlot + .row gap so icons line up with .timestamp */ + padding: 10px 12px 10px calc(12px + 1.25rem + 10px); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.toolbar :global(.icon-button) { + padding: 8px; + border-radius: 8px; +} + +.scrollContainer { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 0 12px; + border: none; + background: none; + cursor: pointer; + font: inherit; + color: inherit; + text-align: left; +} + +.row:hover { + background: var(--color-icon-button-utility-hover-bg); +} + +.dotSlot { + flex-shrink: 0; + width: 1.25rem; + display: flex; + align-items: center; + justify-content: center; +} + +.selectionDot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--color-alert-question); +} + +.timeCell { + display: flex; + align-items: baseline; + flex-wrap: nowrap; + gap: 0.2em; + min-width: 0; +} + +.timestamp { + white-space: nowrap; +} + +.suffix { + margin-left: 0; + color: var(--color-foreground-secondary, #888); + font-size: 0.85em; + white-space: nowrap; +} diff --git a/packages/ui-components/src/history_navigator.stories.tsx b/packages/ui-components/src/history_navigator.stories.tsx new file mode 100644 index 000000000..e5071ec5a --- /dev/null +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -0,0 +1,194 @@ +import { createMemo, createSignal } from "solid-js"; +import type { Meta, StoryObj } from "storybook-solidjs-vite"; + +import { Button } from "./button"; +import { HistoryNavigator, type HistoryItem } from "./history_navigator"; + +const meta = { + title: "Misc/HistoryNavigator", + component: HistoryNavigator, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Example history: a tree of ~20 snapshots. +// +// Timeline (minutes from start): +// 0 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 +// \ \ +// 11 - 12 - 13 19 - 20 +// \ +// 14 - 15 - 16 - 17 - 18 +// +// The "main" branch is 1..10, with side branches at 4 and 10. +// Forward navigation always picks the newest child. +// --------------------------------------------------------------------------- + +type HistoryEntry = { + createdAt: number; + parent: string | null; +}; + +const BASE_TIME = Date.now() - 3 * 60 * 60 * 1000; +const INTERVAL = 10 * 60 * 1000; + +function ts(index: number): number { + return BASE_TIME + index * INTERVAL; +} + +function entry(id: number, parent: number | null, timeIndex: number): [string, HistoryEntry] { + return [ + String(id), + { + createdAt: ts(timeIndex), + parent: parent != null ? String(parent) : null, + }, + ]; +} + +const initialEntries: [string, HistoryEntry][] = [ + entry(1, null, 0), + entry(2, 1, 1), + entry(3, 2, 2), + entry(4, 3, 3), + entry(5, 4, 4), + entry(6, 5, 5), + entry(7, 6, 6), + entry(8, 7, 7), + entry(9, 8, 8), + entry(10, 9, 9), + entry(11, 4, 5.5), + entry(12, 11, 6.5), + entry(13, 12, 7.5), + entry(14, 13, 8.5), + entry(15, 14, 9.5), + entry(16, 15, 10.5), + entry(17, 16, 11.5), + entry(18, 17, 12.5), + entry(19, 10, 10.5), + entry(20, 19, 11.5), +]; + +function makeInitialHistory(): Record { + return Object.fromEntries(initialEntries); +} + +function newestChild( + snapshotId: string, + history: Record, +): string | null { + let best: string | null = null; + let bestTime = -Infinity; + for (const [id, e] of Object.entries(history)) { + if (e.parent === snapshotId && e.createdAt > bestTime) { + best = id; + bestTime = e.createdAt; + } + } + return best; +} + +function buildFullChain(head: string, history: Record): string[] { + const backwards: string[] = []; + let current: string | null = head; + while (current != null && history[current] != null) { + backwards.push(current); + current = history[current]!.parent ?? null; + } + backwards.reverse(); + + let tip: string | null = newestChild(head, history); + while (tip != null) { + backwards.push(tip); + tip = newestChild(tip, history); + } + + return backwards; +} + +function chainToItems(chain: string[], head: string, history: Record): HistoryItem[] { + const items: HistoryItem[] = []; + for (let i = chain.length - 1; i >= 0; i--) { + const id = chain[i]!; + const e = history[id]; + if (e) { + items.push({ id, createdAt: e.createdAt, active: id === head }); + } + } + return items; +} + +function InteractiveStory(props: { initialHead: string }) { + const [head, setHead] = createSignal(props.initialHead); + const [history, setHistory] = createSignal(makeInitialHistory()); + const [nextId, setNextId] = createSignal(21); + + const chain = createMemo(() => buildFullChain(head(), history())); + const items = createMemo(() => chainToItems(chain(), head(), history())); + + const currentIndex = createMemo(() => chain().indexOf(head())); + const canUndo = createMemo(() => currentIndex() > 0); + const canRedo = createMemo(() => newestChild(head(), history()) != null); + + const onUndo = () => { + const idx = currentIndex(); + const prev = chain()[idx - 1]; + if (idx > 0 && prev != null) setHead(prev); + }; + + const onRedo = () => { + const child = newestChild(head(), history()); + if (child != null) setHead(child); + }; + + const simulateChange = () => { + const id = nextId(); + const parentId = head(); + const newEntry: HistoryEntry = { + createdAt: Date.now(), + parent: parentId, + }; + setHistory((prev) => ({ ...prev, [String(id)]: newEntry })); + setHead(String(id)); + setNextId(id + 1); + }; + + return ( +
+
+ +
+
+ +
+
+ ); +} + +export const Default: Story = { + render: () => , +}; diff --git a/packages/ui-components/src/history_navigator.tsx b/packages/ui-components/src/history_navigator.tsx new file mode 100644 index 000000000..1ffc773cd --- /dev/null +++ b/packages/ui-components/src/history_navigator.tsx @@ -0,0 +1,165 @@ +import Redo2 from "lucide-solid/icons/redo-2"; +import Undo2 from "lucide-solid/icons/undo-2"; +import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; + +import { IconButton } from "./icon_button"; +import styles from "./history_navigator.module.css"; +import { createVirtualList } from "./virtual_list"; + +export type HistoryItem = { + id: string; + createdAt: number; + active: boolean; +}; + +export type HistoryNavigatorProps = { + /** History entries, newest-first, already linearized by caller. */ + items: HistoryItem[]; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + onSelect: (id: string) => void; +}; + +function formatTimestamp(ms: number): string { + const d = new Date(ms); + const date = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return `${date}, ${time}`; +} + +const ROW_HEIGHT = 44; + +/** Panel for navigating document snapshot history with undo/redo and a scrollable list. */ +export function HistoryNavigator(props: HistoryNavigatorProps) { + const displayItems = createMemo(() => { + const raw = props.items.map((item) => ({ + ...item, + minuteKey: formatTimestamp(item.createdAt), + })); + + const countPerMinute = new Map(); + for (const item of raw) { + countPerMinute.set(item.minuteKey, (countPerMinute.get(item.minuteKey) ?? 0) + 1); + } + + const indexPerMinute = new Map(); + const suffixByIndex = new Map(); + for (let i = raw.length - 1; i >= 0; i--) { + const item = raw[i]!; + const total = countPerMinute.get(item.minuteKey) ?? 1; + if (total > 1) { + const idx = (indexPerMinute.get(item.minuteKey) ?? 0) + 1; + indexPerMinute.set(item.minuteKey, idx); + if (idx >= 2) { + suffixByIndex.set(i, `~${idx}`); + } + } + } + + return raw.map((item, i) => ({ + id: item.id, + active: item.active, + timestamp: item.minuteKey, + suffix: suffixByIndex.get(i) ?? null, + })); + }); + + const activeIndex = createMemo(() => { + const items = displayItems(); + for (let i = 0; i < items.length; i++) { + if (items[i]?.active) return i; + } + return -1; + }); + + const [scrollHeight, setScrollHeight] = createSignal(300); + + const [virtualList, onScroll] = createVirtualList({ + items: displayItems, + rootHeight: scrollHeight, + rowHeight: () => ROW_HEIGHT, + overscanCount: 5, + }); + + let scrollContainerEl: HTMLDivElement | undefined; + + const containerRef = (el: HTMLDivElement) => { + scrollContainerEl = el; + const measure = () => setScrollHeight(el.clientHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + }; + + createEffect(() => { + const idx = activeIndex(); + const el = scrollContainerEl; + if (!el || idx < 0) return; + + const rowTop = idx * ROW_HEIGHT; + const rowBottom = rowTop + ROW_HEIGHT; + const viewTop = el.scrollTop; + const viewBottom = viewTop + el.clientHeight; + + if (rowTop < viewTop) { + el.scrollTop = rowTop; + } else if (rowBottom > viewBottom) { + el.scrollTop = rowBottom - el.clientHeight; + } + }); + + return ( +
+
+ + + + + + +
+
+
+
+ + {(item) => ( + + )} + +
+
+
+
+ ); +} diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 58c4b5a52..34776066c 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -10,6 +10,7 @@ export * from "./expandable_table"; export * from "./fixed_table_editor"; export * from "./foldable"; export * from "./form"; +export * from "./history_navigator"; export * from "./icon_button"; export * from "./inline_input"; export * from "./input_options"; @@ -21,4 +22,5 @@ export * from "./resizable"; export * from "./spinner"; export * from "./text_input"; export * from "./util/keyboard"; +export * from "./virtual_list"; export * from "./warning_banner"; diff --git a/packages/frontend/src/util/virtual_list.ts b/packages/ui-components/src/virtual_list.ts similarity index 100% rename from packages/frontend/src/util/virtual_list.ts rename to packages/ui-components/src/virtual_list.ts From 12bae2408b97e845f7513ba9a275508435df2bc3 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 17:11:40 +0100 Subject: [PATCH 11/38] ENH: Add history_sidebar --- packages/frontend/src/page/document_page.css | 19 +++ packages/frontend/src/page/document_page.tsx | 145 +++++++++++------- .../frontend/src/page/history_sidebar.tsx | 114 ++++++++++++++ 3 files changed, 226 insertions(+), 52 deletions(-) create mode 100644 packages/frontend/src/page/history_sidebar.tsx diff --git a/packages/frontend/src/page/document_page.css b/packages/frontend/src/page/document_page.css index 7671083ba..e6481640b 100644 --- a/packages/frontend/src/page/document_page.css +++ b/packages/frontend/src/page/document_page.css @@ -41,6 +41,25 @@ white-space: nowrap; } +.document-pane-layout { + display: flex; + flex-direction: row; + height: 100%; + min-height: 0; +} + +.document-pane-content { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +.history-sidebar { + width: 260px; + flex-shrink: 0; + border-left: 1px solid var(--color-gray-400); +} + .resizeable-panels { flex: 1; overflow-y: hidden; diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 55ce991c8..cbb997a2a 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -2,6 +2,7 @@ import Resizable, { type ContextValue } from "@corvu/resizable"; import { Title } from "@solidjs/meta"; import { useNavigate, useParams } from "@solidjs/router"; import ChevronsRight from "lucide-solid/icons/chevrons-right"; +import History from "lucide-solid/icons/history"; import Maximize2 from "lucide-solid/icons/maximize-2"; import RotateCcw from "lucide-solid/icons/rotate-ccw"; import { @@ -33,6 +34,7 @@ import { SidebarLayout } from "../page/sidebar_layout"; import { PermissionsButton } from "../user"; import { assertExhaustive } from "../util/assert_exhaustive"; import { DocumentSidebar } from "./document_page_sidebar"; +import { HistorySidebar } from "./history_sidebar"; import "./document_page.css"; @@ -104,6 +106,11 @@ export default function DocumentPage() { navigate(`/${params.subkind}/${params.subref}`); }; + const [primaryHistoryOpen, setPrimaryHistoryOpen] = createSignal(false); + const togglePrimaryHistorySidebar = () => setPrimaryHistoryOpen((v) => !v); + const [secondaryHistoryOpen, setSecondaryHistoryOpen] = createSignal(false); + const toggleSecondaryHistorySidebar = () => setSecondaryHistoryOpen((v) => !v); + const [resizableContext, setResizableContext] = createSignal(); createEffect(() => { const context = resizableContext(); @@ -150,6 +157,8 @@ export default function DocumentPage() { panelSizes={resizableContext()?.sizes()} maximizeSidePanel={maximizeSidePanel} closeSidePanel={closeSidePanel} + togglePrimaryHistorySidebar={togglePrimaryHistorySidebar} + toggleSecondaryHistorySidebar={toggleSecondaryHistorySidebar} /> } sidebarContents={ @@ -181,6 +190,8 @@ export default function DocumentPage() { refetchPrimaryDoc={refetchPrimaryDoc} refetchSecondaryDoc={refetchSecondaryDoc} setResizableContext={setResizableContext} + primaryHistoryOpen={primaryHistoryOpen()} + secondaryHistoryOpen={secondaryHistoryOpen()} /> )} @@ -197,6 +208,8 @@ function SplitPaneToolbar(props: { panelSizes: number[] | undefined; closeSidePanel: () => void; maximizeSidePanel: () => void; + togglePrimaryHistorySidebar: () => void; + toggleSecondaryHistorySidebar: () => void; }) { const secondaryPanelSize = () => props.panelSizes?.[1]; const primaryPanelSize = () => props.panelSizes?.[0]; @@ -206,6 +219,9 @@ function SplitPaneToolbar(props: { + + + @@ -213,6 +229,9 @@ function SplitPaneToolbar(props: { class="primary-permissions-toolbar toolbar" style={{ left: `${(primaryPanelSize() ?? 0) * 100}%` }} > + + + @@ -224,6 +243,7 @@ function SplitPaneToolbar(props: { secondaryDocRef={props.secondaryDocRef} closeSidePanel={props.closeSidePanel} maximizeSidePanel={props.maximizeSidePanel} + toggleHistorySidebar={props.toggleSecondaryHistorySidebar} /> )} @@ -237,6 +257,7 @@ function SecondaryToolbar(props: { secondaryDocRef: DocRef | undefined; closeSidePanel: () => void; maximizeSidePanel: () => void; + toggleHistorySidebar: () => void; }) { return ( <> @@ -262,6 +283,9 @@ function SecondaryToolbar(props: { > {(secondary) => (
+ + + void; refetchSecondaryDoc: () => void; setResizableContext: (context: ContextValue) => void; + primaryHistoryOpen: boolean; + secondaryHistoryOpen: boolean; }) { return ( @@ -297,6 +323,7 @@ function ResizablePanels(props: { docRef={props.primaryDocRef} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} + historySidebarOpen={props.primaryHistoryOpen} /> @@ -314,6 +341,7 @@ function ResizablePanels(props: { docRef={secondaryLiveDocWithRef().docRef} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} + historySidebarOpen={props.secondaryHistoryOpen} /> )} @@ -331,6 +359,7 @@ export function DocumentPane(props: { docRef: DocRef; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; + historySidebarOpen: boolean; }) { const api = useApi(); const [isDeleted, setIsDeleted] = createSignal(false); @@ -366,59 +395,71 @@ export function DocumentPane(props: { const canRestore = () => props.docRef.permissions.user === "Own"; return ( - <> - - - - - } - > - This {props.doc.type} has been deleted and will not be listed in your documents. - - -
- - - {(liveModel) => } - - - {(liveDiagram) => ( - - - - )} - - - {(liveAnalysis) => ( - - - - )} - - - - - {(liveModel) => } - - - {(liveDiagram) => } - - - {(liveAnalysis) => } - - +
+
+ + + + + } + > + This {props.doc.type} has been deleted and will not be listed in your + documents. + + +
+ + + {(liveModel) => } + + + {(liveDiagram) => ( + + + + )} + + + {(liveAnalysis) => ( + + + + )} + + + + + {(liveModel) => } + + + {(liveDiagram) => ( + + )} + + + {(liveAnalysis) => ( + + )} + + +
- + +
+ +
+
+
); } diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx new file mode 100644 index 000000000..56773c90b --- /dev/null +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -0,0 +1,114 @@ +import { createMemo } from "solid-js"; + +import { HistoryNavigator, type HistoryItem } from "catcolab-ui-components"; +import type { SnapshotInfo } from "catcolab-api/src/user_state"; + +import { useApi } from "../api"; +import { useUserState } from "../user/user_state_context"; + +/** Walk backwards from `head` to root, then forward via newest children to the tip. */ +function buildFullChain( + head: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): string[] { + const backwards: string[] = []; + let current: string | null = head; + while (current != null && snapshots[current] != null) { + backwards.push(current); + const parent: number | null = snapshots[current]!.parent ?? null; + current = parent != null ? String(parent) : null; + } + backwards.reverse(); + + let tip: string | null = newestChild(head, snapshots); + while (tip != null) { + backwards.push(tip); + tip = newestChild(tip, snapshots); + } + + return backwards; +} + +function newestChild( + snapshotId: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): string | null { + let best: string | null = null; + let bestTime = -Infinity; + const numericId = Number.parseInt(snapshotId, 10); + for (const [id, entry] of Object.entries(snapshots)) { + if (entry != null && entry.parent === numericId && entry.createdAt > bestTime) { + best = id; + bestTime = entry.createdAt; + } + } + return best; +} + +function chainToItems( + chain: string[], + head: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): HistoryItem[] { + const items: HistoryItem[] = []; + for (let i = chain.length - 1; i >= 0; i--) { + const id = chain[i]!; + const entry = snapshots[id]; + if (entry) { + items.push({ id, createdAt: entry.createdAt, active: id === head }); + } + } + return items; +} + +export function HistorySidebar(props: { refId: string }) { + const api = useApi(); + const userState = useUserState(); + + const docInfo = createMemo(() => userState.documents[props.refId]); + const head = createMemo(() => { + const cs = docInfo()?.currentSnapshot; + return cs != null ? String(cs) : ""; + }); + const snapshots = createMemo(() => docInfo()?.snapshots ?? {}); + + const chain = createMemo(() => { + const h = head(); + return h ? buildFullChain(h, snapshots()) : []; + }); + + const items = createMemo(() => chainToItems(chain(), head(), snapshots())); + + const currentIndex = createMemo(() => chain().indexOf(head())); + const canUndo = createMemo(() => currentIndex() > 0); + const canRedo = createMemo(() => newestChild(head(), snapshots()) != null); + + const navigate = (snapshotId: string) => { + const id = Number.parseInt(snapshotId, 10); + if (!Number.isNaN(id)) { + void api.rpc.set_current_snapshot.mutate(props.refId, id); + } + }; + + const onUndo = () => { + const idx = currentIndex(); + const prev = chain()[idx - 1]; + if (idx > 0 && prev != null) navigate(prev); + }; + + const onRedo = () => { + const child = newestChild(head(), snapshots()); + if (child != null) navigate(child); + }; + + return ( + + ); +} From 55aadb584e414f763bd1cb308c27d015687fc67c Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Mon, 30 Mar 2026 21:48:35 +0100 Subject: [PATCH 12/38] ENH: Replace the doc when restoring snapshots --- packages/backend/src/document.rs | 63 +++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 2f1a4f5ef..b5bb74595 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -201,29 +201,66 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr Ok(()) } -/// Sets the current snapshot for a ref. +/// Sets the current snapshot for a ref by applying the snapshot's state to the +/// live Automerge document. /// -/// The snapshot must belong to the given ref. The live Automerge document is -/// unchanged since it retains the full history; only the database pointer -/// is updated. +/// The document is updated in-place: the target snapshot's state is read from +/// the Automerge history via its stored heads, then applied as new operations +/// (delete all root keys + repopulate). The `doc_id` is unchanged so connected +/// clients receive the update via normal Automerge sync. pub async fn set_current_snapshot( state: AppState, ref_id: Uuid, snapshot_id: i32, ) -> Result<(), AppError> { - let rows_affected = sqlx::query!( - "UPDATE refs SET current_snapshot = $2 WHERE id = $1 - AND EXISTS (SELECT 1 FROM snapshots WHERE id = $2 AND for_ref = $1)", - ref_id, + use automerge::ReadDoc as _; + use automerge::transaction::Transactable as _; + + let snapshot = sqlx::query!( + "SELECT heads FROM snapshots WHERE id = $1 AND for_ref = $2", snapshot_id, + ref_id, ) - .execute(&state.db) + .fetch_optional(&state.db) .await? - .rows_affected(); + .ok_or_else(|| AppError::Invalid("Snapshot not found for this ref".to_string()))?; - if rows_affected == 0 { - return Err(AppError::Invalid("Snapshot not found for this ref".to_string())); - } + let target_heads: Vec = snapshot + .heads + .iter() + .map(|h| automerge::ChangeHash(h.as_slice().try_into().expect("invalid change hash"))) + .collect(); + + let doc_id = get_doc_id(state.clone(), ref_id).await?; + let doc_handle = state + .repo + .find(doc_id) + .await? + .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; + + doc_handle.with_document(|doc| -> Result<(), AppError> { + let target_state = hydrate_to_json(&doc.hydrate(Some(&target_heads))); + + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let keys: Vec = tx.keys(automerge::ROOT).collect(); + for key in &keys { + tx.delete(automerge::ROOT, key.as_str())?; + } + populate_automerge_from_json(tx, automerge::ROOT, &target_state)?; + Ok(()) + }) + .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; + + Ok(()) + })?; + + sqlx::query!( + "UPDATE refs SET current_snapshot = $2 WHERE id = $1", + ref_id, + snapshot_id, + ) + .execute(&state.db) + .await?; if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); From b0e0508e71bc9893d8bdfefcb1708cdc9e31fb84 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 13:25:23 +0100 Subject: [PATCH 13/38] ENH: Test and fix snapshot creation --- packages/backend/src/document.rs | 46 +- .../frontend/src/api/document_editing.test.ts | 583 ++++++++++++++++++ 2 files changed, 598 insertions(+), 31 deletions(-) create mode 100644 packages/frontend/src/api/document_editing.test.ts diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index b5bb74595..107994225 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -143,53 +143,37 @@ pub async fn ref_deleted_at( Ok(query.fetch_one(&state.db).await?.deleted_at) } -/// Saves the document by replacing the head with a new snapshot. -/// -/// The snapshot at the previous head is *not* deleted. +/// Saves the document by creating a new snapshot and setting the current_snapshot to it. pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppError> { - let query = sqlx::query!( - " - SELECT doc_id FROM refs WHERE id = $1 - ", - ref_id - ); + let doc_id = get_doc_id(state.clone(), ref_id).await?; - let doc_id = query.fetch_one(&state.db).await?.doc_id; - let doc_id: samod::DocumentId = doc_id - .parse() - .map_err(|_| AppError::Invalid("Invalid document ID".to_string()))?; + let doc_handle = state + .repo + .find(doc_id) + .await? + .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - let (cloned_doc, heads, doc_content) = { - let doc_handle = state - .repo - .find(doc_id) - .await? - .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - - doc_handle.with_document(|doc| { - let heads: Vec> = doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); - let hydrated = doc.hydrate(None); - let doc_content = hydrate_to_json(&hydrated); - (doc.clone(), heads, doc_content) - }) - }; - let cloned_handle = state.repo.create(cloned_doc).await?; + let (heads, doc_content) = doc_handle.with_document(|doc| { + let heads: Vec> = doc.get_heads().iter().map(|h| h.0.to_vec()).collect(); + let hydrated = doc.hydrate(None); + let doc_content = hydrate_to_json(&hydrated); + (heads, doc_content) + }); sqlx::query( " WITH snapshot AS ( INSERT INTO snapshots(for_ref, content, created_at, heads, parent) - VALUES ($1, $2, NOW(), $4, (SELECT current_snapshot FROM refs WHERE id = $1)) + VALUES ($1, $2, NOW(), $3, (SELECT current_snapshot FROM refs WHERE id = $1)) RETURNING id ) UPDATE refs - SET current_snapshot = (SELECT id FROM snapshot), doc_id = $3 + SET current_snapshot = (SELECT id FROM snapshot) WHERE id = $1 ", ) .bind(ref_id) .bind(doc_content) - .bind(cloned_handle.document_id().to_string()) .bind(&heads) .execute(&state.db) .await?; diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts new file mode 100644 index 000000000..b337822b8 --- /dev/null +++ b/packages/frontend/src/api/document_editing.test.ts @@ -0,0 +1,583 @@ +import { type DocHandle, isValidDocumentId, Repo } from "@automerge/automerge-repo"; +import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import type { DocInfo, UserState } from "catcolab-api/src/user_state"; +import { type FirebaseOptions, initializeApp } from "firebase/app"; +import { deleteUser, getAuth, signInWithEmailAndPassword } from "firebase/auth"; +import invariant from "tiny-invariant"; +import { v4 } from "uuid"; +import { afterAll, assert, describe, test } from "vitest"; + +import type { Document } from "catlog-wasm"; +import type { ModelDocument } from "../model/document"; +import { normalizeImmutableStrings } from "../util/immutable_string"; +import { createTestDocument, initTestUserAuth } from "../util/test_util.ts"; +import { createFetchWithAuth, createRpcClient, unwrap } from "./rpc.ts"; + +const serverUrl = import.meta.env.VITE_SERVER_URL; +const repoUrl = import.meta.env.VITE_AUTOMERGE_REPO_URL; +const firebaseOptions = JSON.parse(import.meta.env.VITE_FIREBASE_OPTIONS) as FirebaseOptions; + +const firebaseApp = initializeApp(firebaseOptions); +const rpc = createRpcClient(serverUrl, createFetchWithAuth(firebaseApp)); + +const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(repoUrl)], +}); + +const waitFor = async ( + condition: () => boolean, + message: string, + timeoutMs = 15000, + intervalMs = 100, +) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + assert(condition(), message); +}; + +describe("Document editing, snapshots, and undo/redo", async () => { + const auth = getAuth(firebaseApp); + const email = "test-doc-editing@catcolab.org"; + const password = "foobar"; + await initTestUserAuth(auth, email, password); + + const user = auth.currentUser; + invariant(user); + + const createdRefs: string[] = []; + afterAll(async () => { + for (const id of createdRefs) { + await rpc.delete_ref.mutate(id).catch(() => {}); + } + await deleteUser(user); + repo.shutdown(); + }); + + unwrap(await rpc.sign_up_or_sign_in.mutate()); + + const userStateDocId = unwrap(await rpc.get_user_state_doc_id.query()); + assert(isValidDocumentId(userStateDocId)); + + const userStateHandle: DocHandle = await repo.find(userStateDocId); + await userStateHandle.whenReady(); + + let latestState = userStateHandle.doc(); + userStateHandle.on("change", ({ doc }) => { + latestState = normalizeImmutableStrings(doc); + }); + + const findDoc = (refId: string): DocInfo | undefined => latestState?.documents[refId]; + + const createDoc = async (name: string): Promise => { + const refId = unwrap(await rpc.new_ref.mutate(createTestDocument(name))); + createdRefs.push(refId); + return refId; + }; + + const getLiveHandle = async (refId: string): Promise> => { + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live", "Document should be live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); + return handle; + }; + + // --------------------------------------------------------------- + // Test 1: Editing a document via Automerge propagates changes + // --------------------------------------------------------------- + test.sequential("should edit document name via Automerge handle", async () => { + const name = `Edit Test - ${v4()}`; + const refId = await createDoc(name); + const handle = await getLiveHandle(refId); + + assert.strictEqual(handle.doc().name, name); + + const newName = `Edited - ${v4()}`; + handle.change((doc) => { + doc.name = newName; + }); + + assert.strictEqual(handle.doc().name, newName); + }); + + // --------------------------------------------------------------- + // Test 2: Autosave creates a second snapshot after edits + // --------------------------------------------------------------- + test.sequential("should create a new snapshot via autosave after editing", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Autosave Test - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc, "Document should exist"); + assert.strictEqual( + Object.keys(initialDoc.snapshots).length, + 1, + "Should start with one snapshot", + ); + + const handle = await getLiveHandle(refId); + + const newName = `Autosaved - ${v4()}`; + handle.change((doc) => { + doc.name = newName; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have a second snapshot after autosave"); + + const afterDoc = findDoc(refId); + assert(afterDoc); + assert.strictEqual(Object.keys(afterDoc.snapshots).length, 2, "Should have two snapshots"); + }); + + // --------------------------------------------------------------- + // Test 3: Snapshot chain has correct parent/child structure + // --------------------------------------------------------------- + test.sequential("should have correct parent/child snapshot structure", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Chain Test - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Object.keys(initialDoc.snapshots)[0]!; + const originalSnapshot = initialDoc.snapshots[originalSnapshotId]!; + assert.strictEqual(originalSnapshot.parent, null, "Root snapshot should have no parent"); + + const handle = await getLiveHandle(refId); + handle.change((doc) => { + doc.name = `Chain V2 - ${v4()}`; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots"); + + const afterDoc = findDoc(refId); + assert(afterDoc); + const snapshotIds = Object.keys(afterDoc.snapshots); + assert.strictEqual(snapshotIds.length, 2, "Should have exactly two snapshots"); + + const newSnapshotId = snapshotIds.find((id) => id !== originalSnapshotId)!; + const newSnapshot = afterDoc.snapshots[newSnapshotId]!; + assert.strictEqual( + newSnapshot.parent, + Number(originalSnapshotId), + "Second snapshot should have first as parent", + ); + }); + + // --------------------------------------------------------------- + // Test 4: Explicit create_snapshot RPC works + // --------------------------------------------------------------- + test.sequential("should create a snapshot via explicit RPC call", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Explicit Snapshot - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + assert.strictEqual(Object.keys(initialDoc.snapshots).length, 1); + + unwrap(await rpc.create_snapshot.mutate(refId)); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after explicit create_snapshot"); + + const afterDoc = findDoc(refId); + assert(afterDoc); + assert.strictEqual(Object.keys(afterDoc.snapshots).length, 2); + }); + + // --------------------------------------------------------------- + // Test 5: set_current_snapshot reverts the live document content + // --------------------------------------------------------------- + test.sequential( + "should revert live document content when navigating to an older snapshot", + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const originalName = `Revert Original - ${v4()}`; + const refId = await createDoc(originalName); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + assert.strictEqual(handle.doc().name, originalName); + + const editedName = `Revert Edited - ${v4()}`; + handle.change((doc) => { + doc.name = editedName; + }); + assert.strictEqual(handle.doc().name, editedName); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); + + // Navigate back to the original snapshot. + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + + // The live Automerge document should revert to the original content. + await waitFor( + () => handle.doc().name === originalName, + `Live document name should revert to "${originalName}" but is "${handle.doc().name}"`, + ); + + assert.strictEqual( + handle.doc().name, + originalName, + "Live document should have original name after reverting", + ); + }, + ); + + // --------------------------------------------------------------- + // Test 6: set_current_snapshot updates the database snapshot content + // --------------------------------------------------------------- + test.sequential( + "should update head_snapshot content after navigating to an older snapshot", + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const originalName = `DB Revert Original - ${v4()}`; + const refId = await createDoc(originalName); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + + const editedName = `DB Revert Edited - ${v4()}`; + handle.change((doc) => { + doc.name = editedName; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); + + // Verify the head_snapshot shows the edited version. + const editedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< + string, + unknown + >; + assert.strictEqual(editedContent.name, editedName, "head_snapshot should show edited name"); + + // Navigate back to original. + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + + // The head_snapshot should now point to the original snapshot's content. + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && doc.currentSnapshot === originalSnapshotId; + }, "currentSnapshot should point to original"); + + const revertedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< + string, + unknown + >; + assert.strictEqual( + revertedContent.name, + originalName, + "head_snapshot should show original name after revert", + ); + }, + ); + + // --------------------------------------------------------------- + // Test 7: Multiple edits → multiple snapshots → navigate history + // --------------------------------------------------------------- + test.sequential("should navigate through a chain of three snapshots", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name1 = `History V1 - ${v4()}`; + const refId = await createDoc(name1); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const snapshot1Id = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + + // Edit 1 → autosave → snapshot 2 + const name2 = `History V2 - ${v4()}`; + handle.change((doc) => { + doc.name = name2; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after first edit"); + + const after2 = findDoc(refId); + assert(after2); + const snapshot2Id = after2.currentSnapshot; + assert(snapshot2Id !== snapshot1Id); + + // Edit 2 → autosave → snapshot 3 + const name3 = `History V3 - ${v4()}`; + handle.change((doc) => { + doc.name = name3; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 3; + }, "Should have three snapshots after second edit"); + + const after3 = findDoc(refId); + assert(after3); + const snapshot3Id = after3.currentSnapshot; + assert(snapshot3Id !== snapshot2Id); + + // Navigate back to V1 + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + + await waitFor( + () => handle.doc().name === name1, + `Document should revert to V1 name "${name1}" but is "${handle.doc().name}"`, + ); + assert.strictEqual(handle.doc().name, name1); + + // Navigate forward to V2 + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot2Id)); + + await waitFor( + () => handle.doc().name === name2, + `Document should show V2 name "${name2}" but is "${handle.doc().name}"`, + ); + assert.strictEqual(handle.doc().name, name2); + + // Navigate forward to V3 + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot3Id)); + + await waitFor( + () => handle.doc().name === name3, + `Document should show V3 name "${name3}" but is "${handle.doc().name}"`, + ); + assert.strictEqual(handle.doc().name, name3); + }); + + // --------------------------------------------------------------- + // Test 8: Undo (go to parent) and redo (go to child) + // --------------------------------------------------------------- + test.sequential("should undo and redo through snapshot history", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name1 = `Undo V1 - ${v4()}`; + const refId = await createDoc(name1); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const snapshot1Id = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + + const name2 = `Undo V2 - ${v4()}`; + handle.change((doc) => { + doc.name = name2; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots"); + + const after = findDoc(refId); + assert(after); + const snapshot2Id = after.currentSnapshot; + + // Undo: navigate to parent (snapshot 1) + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + + await waitFor( + () => handle.doc().name === name1, + `Undo should revert to "${name1}" but is "${handle.doc().name}"`, + ); + assert.strictEqual(handle.doc().name, name1, "After undo, name should be V1"); + + // Redo: navigate back to child (snapshot 2) + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot2Id)); + + await waitFor( + () => handle.doc().name === name2, + `Redo should restore to "${name2}" but is "${handle.doc().name}"`, + ); + assert.strictEqual(handle.doc().name, name2, "After redo, name should be V2"); + }); + + // --------------------------------------------------------------- + // Test 9: Editing after undo creates a branch + // --------------------------------------------------------------- + test.sequential("should allow editing after navigating to an older snapshot", async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name1 = `Branch V1 - ${v4()}`; + const refId = await createDoc(name1); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const snapshot1Id = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + + const name2 = `Branch V2 - ${v4()}`; + handle.change((doc) => { + doc.name = name2; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots"); + + // Undo to V1 + unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + + await waitFor( + () => handle.doc().name === name1, + `Should revert to V1 "${name1}" but is "${handle.doc().name}"`, + ); + + // Now edit from V1 (creating a branch) + const name3 = `Branch V3 - ${v4()}`; + handle.change((doc) => { + doc.name = name3; + }); + + assert.strictEqual(handle.doc().name, name3, "After branching edit, name should be V3"); + + // Wait for the branch edit to autosave + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 3; + }, "Should have three snapshots after branching edit"); + }); + + // --------------------------------------------------------------- + // Test 10: Content beyond just name is preserved during revert + // --------------------------------------------------------------- + test.sequential( + "should preserve full document structure when reverting snapshots", + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Structure Test - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); + + const doc = handle.doc(); + assert.strictEqual(doc.type, "model", "Document type should be model"); + assert.strictEqual(doc.theory, "empty", "Document theory should be empty"); + assert.deepStrictEqual(doc.notebook.cellOrder, [], "Cell order should be empty"); + + handle.change((doc) => { + doc.name = `Structure Edited - ${v4()}`; + doc.theory = "causal-loop"; + doc.notebook.cellOrder = ["cell1"]; + doc.notebook.cellContents.cell1 = { + tag: "rich-text", + content: "Hello world", + } as any; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after edit"); + + // Revert to original + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + + await waitFor( + () => handle.doc().theory === "empty", + `Theory should revert to "empty" but is "${handle.doc().theory}"`, + ); + + const reverted = handle.doc(); + assert.strictEqual(reverted.name, name, "Name should revert"); + assert.strictEqual(reverted.theory, "empty", "Theory should revert to empty"); + assert.deepStrictEqual( + reverted.notebook.cellOrder, + [], + "Cell order should revert to empty", + ); + }, + ); +}); From c115302a2637b50b24c8fbec038f56b1d48a4e19 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 13:59:03 +0100 Subject: [PATCH 14/38] ENH: Properly suppress autosave when loading snapshots --- ...3da5238ede1773a9507446694e9f7b4d2b16c.json | 16 ----- ...1549379c095c56d128fa5ff64a6e0583c1157.json | 17 ----- ...47b10a291e6e98d2b989036e8d78ee3b00afd.json | 15 ----- ...1472860299d4fc872194449e18682cfed5bab.json | 22 ------- ...2ae5906114a973fcbd111f70bfd9fc8b2655d.json | 64 ------------------- ...d674427db3a290d6db645dea5ac3c540167ef.json | 22 ------- ...64c3603e238ea6786d327f64ae04aef205198.json | 16 ----- ...af6b2e605f2554dee55bfcb8a9b9b24fde70d.json | 14 ---- packages/backend/src/app.rs | 3 + packages/backend/src/autosave.rs | 6 ++ packages/backend/src/document.rs | 54 +++++++++------- packages/backend/src/main.rs | 1 + packages/backend/tests/common/test_utils.rs | 1 + packages/backend/tests/user_state_tests.rs | 1 + .../frontend/src/api/document_editing.test.ts | 62 ++++++++++++++++++ 15 files changed, 105 insertions(+), 209 deletions(-) delete mode 100644 packages/backend/.sqlx/query-05183554f0c9207cdd1e89a948d3da5238ede1773a9507446694e9f7b4d2b16c.json delete mode 100644 packages/backend/.sqlx/query-3afc60b9a08994afbbeab81064c1549379c095c56d128fa5ff64a6e0583c1157.json delete mode 100644 packages/backend/.sqlx/query-4f04b1b2b42a310d659df5700aa47b10a291e6e98d2b989036e8d78ee3b00afd.json delete mode 100644 packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json delete mode 100644 packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json delete mode 100644 packages/backend/.sqlx/query-92973a6dc72749d528465057e8bd674427db3a290d6db645dea5ac3c540167ef.json delete mode 100644 packages/backend/.sqlx/query-a8cb9f26893bbf784cf8a71090e64c3603e238ea6786d327f64ae04aef205198.json delete mode 100644 packages/backend/.sqlx/query-f3c3b43e6a2e16d005b619c2e0caf6b2e605f2554dee55bfcb8a9b9b24fde70d.json diff --git a/packages/backend/.sqlx/query-05183554f0c9207cdd1e89a948d3da5238ede1773a9507446694e9f7b4d2b16c.json b/packages/backend/.sqlx/query-05183554f0c9207cdd1e89a948d3da5238ede1773a9507446694e9f7b4d2b16c.json deleted file mode 100644 index 133477f56..000000000 --- a/packages/backend/.sqlx/query-05183554f0c9207cdd1e89a948d3da5238ede1773a9507446694e9f7b4d2b16c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH snapshot AS (\n INSERT INTO snapshots(for_ref, content, last_updated, doc_id)\n VALUES ($1, $2, NOW(), $3)\n RETURNING id\n )\n UPDATE refs\n SET head = (SELECT id FROM snapshot)\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "05183554f0c9207cdd1e89a948d3da5238ede1773a9507446694e9f7b4d2b16c" -} diff --git a/packages/backend/.sqlx/query-3afc60b9a08994afbbeab81064c1549379c095c56d128fa5ff64a6e0583c1157.json b/packages/backend/.sqlx/query-3afc60b9a08994afbbeab81064c1549379c095c56d128fa5ff64a6e0583c1157.json deleted file mode 100644 index 5ccbd6acc..000000000 --- a/packages/backend/.sqlx/query-3afc60b9a08994afbbeab81064c1549379c095c56d128fa5ff64a6e0583c1157.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH snapshot AS (\n INSERT INTO snapshots (for_ref, content, last_updated, doc_id)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n )\n INSERT INTO refs (id, head, created)\n VALUES ($1, (SELECT id FROM snapshot), $3)\n ON CONFLICT (id) DO UPDATE SET head = (SELECT id FROM snapshot)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Timestamptz", - "Text" - ] - }, - "nullable": [] - }, - "hash": "3afc60b9a08994afbbeab81064c1549379c095c56d128fa5ff64a6e0583c1157" -} diff --git a/packages/backend/.sqlx/query-4f04b1b2b42a310d659df5700aa47b10a291e6e98d2b989036e8d78ee3b00afd.json b/packages/backend/.sqlx/query-4f04b1b2b42a310d659df5700aa47b10a291e6e98d2b989036e8d78ee3b00afd.json deleted file mode 100644 index 3bd8026c3..000000000 --- a/packages/backend/.sqlx/query-4f04b1b2b42a310d659df5700aa47b10a291e6e98d2b989036e8d78ee3b00afd.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE snapshots\n SET content = $2, last_updated = NOW()\n WHERE id = (SELECT head FROM refs WHERE id = $1)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "4f04b1b2b42a310d659df5700aa47b10a291e6e98d2b989036e8d78ee3b00afd" -} diff --git a/packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json b/packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json deleted file mode 100644 index baac83349..000000000 --- a/packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT doc_id FROM snapshots\n WHERE id = (SELECT head FROM refs WHERE id = $1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "doc_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab" -} diff --git a/packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json b/packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json deleted file mode 100644 index f93a4542b..000000000 --- a/packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH\n filtered_ids AS (\n SELECT refs.id\n FROM refs\n WHERE\n -- filter by minimum permission level (read)\n get_max_permission($1, refs.id) >= 'read'::permission_level\n -- exclude public-only documents (user must have explicit permission)\n AND EXISTS (\n SELECT 1\n FROM permissions p_searcher\n WHERE\n p_searcher.object = refs.id\n AND p_searcher.subject = $1\n )\n )\n SELECT\n refs.id AS \"ref_id!\",\n snapshots.content->>'name' AS name,\n snapshots.content->>'type' AS type_name,\n snapshots.content->>'theory' AS theory,\n refs.created AS \"created_at!\",\n refs.deleted_at,\n snapshots.content AS \"content!\",\n COALESCE(\n (SELECT json_agg(json_build_object(\n 'user', p.subject,\n 'level', INITCAP(p.level::text)\n ) ORDER BY p.level DESC)\n FROM permissions p\n WHERE p.object = refs.id\n ), '[]'::json\n ) AS \"permissions!: sqlx::types::Json>\"\n FROM filtered_ids\n JOIN refs ON refs.id = filtered_ids.id\n JOIN snapshots ON snapshots.id = refs.head\n ORDER BY refs.created DESC;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "ref_id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "type_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "theory", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "created_at!", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "deleted_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "content!", - "type_info": "Jsonb" - }, - { - "ordinal": 7, - "name": "permissions!: sqlx::types::Json>", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - null, - null, - false, - true, - false, - null - ] - }, - "hash": "7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d" -} diff --git a/packages/backend/.sqlx/query-92973a6dc72749d528465057e8bd674427db3a290d6db645dea5ac3c540167ef.json b/packages/backend/.sqlx/query-92973a6dc72749d528465057e8bd674427db3a290d6db645dea5ac3c540167ef.json deleted file mode 100644 index f7da92c4f..000000000 --- a/packages/backend/.sqlx/query-92973a6dc72749d528465057e8bd674427db3a290d6db645dea5ac3c540167ef.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT content FROM snapshots\n WHERE id = (SELECT head FROM refs WHERE id = $1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "content", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "92973a6dc72749d528465057e8bd674427db3a290d6db645dea5ac3c540167ef" -} diff --git a/packages/backend/.sqlx/query-a8cb9f26893bbf784cf8a71090e64c3603e238ea6786d327f64ae04aef205198.json b/packages/backend/.sqlx/query-a8cb9f26893bbf784cf8a71090e64c3603e238ea6786d327f64ae04aef205198.json deleted file mode 100644 index 73cc17657..000000000 --- a/packages/backend/.sqlx/query-a8cb9f26893bbf784cf8a71090e64c3603e238ea6786d327f64ae04aef205198.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH snapshot AS (\n INSERT INTO snapshots(for_ref, content, last_updated, doc_id)\n VALUES ($1, $2, NOW(), $3)\n RETURNING id\n )\n INSERT INTO refs(id, head, created)\n VALUES ($1, (SELECT id FROM snapshot), NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a8cb9f26893bbf784cf8a71090e64c3603e238ea6786d327f64ae04aef205198" -} diff --git a/packages/backend/.sqlx/query-f3c3b43e6a2e16d005b619c2e0caf6b2e605f2554dee55bfcb8a9b9b24fde70d.json b/packages/backend/.sqlx/query-f3c3b43e6a2e16d005b619c2e0caf6b2e605f2554dee55bfcb8a9b9b24fde70d.json deleted file mode 100644 index 5fba79249..000000000 --- a/packages/backend/.sqlx/query-f3c3b43e6a2e16d005b619c2e0caf6b2e605f2554dee55bfcb8a9b9b24fde70d.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (id, created, signed_in)\n VALUES ($1, NOW(), NOW())\n ON CONFLICT (id) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "f3c3b43e6a2e16d005b619c2e0caf6b2e605f2554dee55bfcb8a9b9b24fde70d" -} diff --git a/packages/backend/src/app.rs b/packages/backend/src/app.rs index 0d7bc0281..249d52675 100644 --- a/packages/backend/src/app.rs +++ b/packages/backend/src/app.rs @@ -21,6 +21,9 @@ pub struct AppState { /// Tracks which ref_ids have active autosave listeners to prevent duplicates. pub active_listeners: Arc>>, + /// Ref IDs whose next autosave should be skipped (e.g., during snapshot navigation). + pub suppress_autosave: Arc>>, + /// Tracks user IDs whose state docs were refreshed from DB in this process, /// mapped to their Automerge document IDs. pub initialized_user_states: Arc>>, diff --git a/packages/backend/src/autosave.rs b/packages/backend/src/autosave.rs index 23ce53129..0a55903be 100644 --- a/packages/backend/src/autosave.rs +++ b/packages/backend/src/autosave.rs @@ -30,6 +30,12 @@ pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: let mut snapshot_handle: Option> = None; while (changes.next().await).is_some() { + if state.suppress_autosave.read().await.contains(&ref_id) { + if let Some(handle) = snapshot_handle.take() { + handle.abort(); + } + continue; + } if let Some(handle) = snapshot_handle.take() { handle.abort(); } diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 107994225..8a04e0e2e 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -2,9 +2,9 @@ use crate::app::{AppCtx, AppError, AppState}; use crate::autosave::ensure_autosave_listener; -use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; +use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; use samod::DocumentId; use serde_json::Value; use uuid::Uuid; @@ -222,35 +222,43 @@ pub async fn set_current_snapshot( .await? .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - doc_handle.with_document(|doc| -> Result<(), AppError> { - let target_state = hydrate_to_json(&doc.hydrate(Some(&target_heads))); + state.suppress_autosave.write().await.insert(ref_id); + + let result: Result<(), AppError> = async { + doc_handle.with_document(|doc| -> Result<(), AppError> { + let target_state = hydrate_to_json(&doc.hydrate(Some(&target_heads))); + + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let keys: Vec = tx.keys(automerge::ROOT).collect(); + for key in &keys { + tx.delete(automerge::ROOT, key.as_str())?; + } + populate_automerge_from_json(tx, automerge::ROOT, &target_state)?; + Ok(()) + }) + .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let keys: Vec = tx.keys(automerge::ROOT).collect(); - for key in &keys { - tx.delete(automerge::ROOT, key.as_str())?; - } - populate_automerge_from_json(tx, automerge::ROOT, &target_state)?; Ok(()) - }) - .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; + })?; - Ok(()) - })?; + sqlx::query!( + "UPDATE refs SET current_snapshot = $2 WHERE id = $1", + ref_id, + snapshot_id, + ) + .execute(&state.db) + .await?; - sqlx::query!( - "UPDATE refs SET current_snapshot = $2 WHERE id = $1", - ref_id, - snapshot_id, - ) - .execute(&state.db) - .await?; + if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { + tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); + } - if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { - tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); + Ok(()) } + .await; - Ok(()) + state.suppress_autosave.write().await.remove(&ref_id); + result } /// Soft-deletes a document reference by setting `deleted_at`. diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index 89c903b03..86b38e71d 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -137,6 +137,7 @@ async fn main() { db: db.clone(), repo, active_listeners: Arc::new(RwLock::new(HashSet::new())), + suppress_autosave: Arc::new(RwLock::new(HashSet::new())), initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client, julia_url, diff --git a/packages/backend/tests/common/test_utils.rs b/packages/backend/tests/common/test_utils.rs index a53067c0d..e29d1563b 100644 --- a/packages/backend/tests/common/test_utils.rs +++ b/packages/backend/tests/common/test_utils.rs @@ -49,6 +49,7 @@ pub async fn create_test_app_state(pool: PgPool) -> AppState { initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, + suppress_autosave: Arc::new(RwLock::new(HashSet::new())), } } diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index 76ededd9b..61e7d69c8 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -1114,6 +1114,7 @@ mod integration_tests { initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, + suppress_autosave: Arc::new(RwLock::new(HashSet::new())), }; let expected_state = diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index b337822b8..18eb29707 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -580,4 +580,66 @@ describe("Document editing, snapshots, and undo/redo", async () => { ); }, ); + + // --------------------------------------------------------------- + // Test 11: set_current_snapshot should NOT create a spurious snapshot + // --------------------------------------------------------------- + test.sequential( + "should not create extra snapshots when navigating to a historical snapshot", + { timeout: 15000 }, + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `No Spurious Snapshot - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + + const handle = await getLiveHandle(refId); + + const editedName = `Spurious Edited - ${v4()}`; + handle.change((doc) => { + doc.name = editedName; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); + + const beforeRevert = findDoc(refId); + assert(beforeRevert); + assert.strictEqual( + Object.keys(beforeRevert.snapshots).length, + 2, + "Should have exactly two snapshots before revert", + ); + + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + + await waitFor( + () => handle.doc().name === name, + `Live document name should revert to "${name}"`, + ); + + // Wait well past the autosave debounce (500ms) to ensure no + // spurious snapshot is created by the revert's document change. + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const afterRevert = findDoc(refId); + assert(afterRevert); + assert.strictEqual( + Object.keys(afterRevert.snapshots).length, + 2, + "Snapshot count should still be 2 — revert must not create a spurious snapshot", + ); + }, + ); }); From dea6d89abaf7f465d57c6cdb034975d57e7b0266 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 14:21:04 +0100 Subject: [PATCH 15/38] ENH: Stop going to json when loading snapshots --- packages/backend/src/document.rs | 15 +- .../frontend/src/api/document_editing.test.ts | 100 +++++++++++ packages/notebook-types/src/automerge_json.rs | 155 ++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 8a04e0e2e..43bb82543 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -4,7 +4,9 @@ use crate::app::{AppCtx, AppError, AppState}; use crate::autosave::ensure_autosave_listener; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; -use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; +use notebook_types::automerge_json::{ + copy_doc_at_heads, hydrate_to_json, populate_automerge_from_json, +}; use samod::DocumentId; use serde_json::Value; use uuid::Uuid; @@ -197,9 +199,6 @@ pub async fn set_current_snapshot( ref_id: Uuid, snapshot_id: i32, ) -> Result<(), AppError> { - use automerge::ReadDoc as _; - use automerge::transaction::Transactable as _; - let snapshot = sqlx::query!( "SELECT heads FROM snapshots WHERE id = $1 AND for_ref = $2", snapshot_id, @@ -226,14 +225,8 @@ pub async fn set_current_snapshot( let result: Result<(), AppError> = async { doc_handle.with_document(|doc| -> Result<(), AppError> { - let target_state = hydrate_to_json(&doc.hydrate(Some(&target_heads))); - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - let keys: Vec = tx.keys(automerge::ROOT).collect(); - for key in &keys { - tx.delete(automerge::ROOT, key.as_str())?; - } - populate_automerge_from_json(tx, automerge::ROOT, &target_state)?; + copy_doc_at_heads(tx, &target_heads)?; Ok(()) }) .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index 18eb29707..9c4a44cdf 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -1,3 +1,4 @@ +import { next as Automerge } from "@automerge/automerge"; import { type DocHandle, isValidDocumentId, Repo } from "@automerge/automerge-repo"; import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; import type { DocInfo, UserState } from "catcolab-api/src/user_state"; @@ -642,4 +643,103 @@ describe("Document editing, snapshots, and undo/redo", async () => { ); }, ); + + // --------------------------------------------------------------- + // Test 12: Rich text marks are preserved through snapshot navigation + // --------------------------------------------------------------- + test.sequential( + "should preserve rich text marks when navigating to a historical snapshot", + { timeout: 15000 }, + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Rich Text Marks - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); + + const cellId = v4(); + + handle.change((doc) => { + doc.notebook.cellOrder.push(cellId); + doc.notebook.cellContents[cellId] = { + tag: "rich-text", + id: cellId, + content: "", + } as any; + Automerge.splice( + doc, + ["notebook", "cellContents", cellId, "content"], + 0, + 0, + "Hello bold world", + ); + Automerge.mark( + doc, + ["notebook", "cellContents", cellId, "content"], + { start: 6, end: 10, expand: "after" }, + "bold", + true, + ); + }); + + const textPath = ["notebook", "cellContents", cellId, "content"] as const; + const marksBeforeSnapshot = Automerge.marks(handle.doc(), textPath as any); + assert.strictEqual(marksBeforeSnapshot.length, 1, "Should have one bold mark"); + assert.strictEqual(marksBeforeSnapshot[0]!.name, "bold"); + assert.strictEqual(marksBeforeSnapshot[0]!.start, 6); + assert.strictEqual(marksBeforeSnapshot[0]!.end, 10); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); + + const afterEdit = findDoc(refId); + assert(afterEdit); + const markedSnapshotId = afterEdit.currentSnapshot; + + handle.change((doc) => { + doc.name = `Rich Text Edited - ${v4()}`; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 3; + }, "Should have three snapshots after second edit"); + + // Navigate back to the snapshot that had marks. + unwrap(await rpc.set_current_snapshot.mutate(refId, markedSnapshotId)); + + await waitFor( + () => handle.doc().name === name, + `Should revert to original name "${name}" but is "${handle.doc().name}"`, + ); + + // The critical assertion: marks must survive the round-trip through + // hydrate_to_json → populate_automerge_from_json. + const marksAfterRevert = Automerge.marks(handle.doc(), textPath as any); + assert.strictEqual( + marksAfterRevert.length, + 1, + `Bold mark should be preserved after snapshot navigation, ` + + `but got ${JSON.stringify(marksAfterRevert)}`, + ); + assert.strictEqual(marksAfterRevert[0]!.name, "bold"); + assert.strictEqual(marksAfterRevert[0]!.start, 6); + assert.strictEqual(marksAfterRevert[0]!.end, 10); + }, + ); }); diff --git a/packages/notebook-types/src/automerge_json.rs b/packages/notebook-types/src/automerge_json.rs index 65e08b9bd..38167c809 100644 --- a/packages/notebook-types/src/automerge_json.rs +++ b/packages/notebook-types/src/automerge_json.rs @@ -117,6 +117,161 @@ pub fn populate_automerge_from_json<'a>( Ok(()) } +/// Overwrite the document root with the state at `target_heads`. +/// +/// Unlike the `hydrate_to_json` + `populate_automerge_from_json` round-trip, +/// this preserves rich-text marks and block markers on Text objects by using +/// `marks_at` / `mark` instead of going through a plain-string intermediary. +pub fn copy_doc_at_heads<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + target_heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ReadDoc; + + let current_keys: Vec = tx.keys(automerge::ROOT).collect(); + for key in ¤t_keys { + tx.delete(automerge::ROOT, key.as_str())?; + } + + let target_entries: Vec<_> = { + let keys: Vec = tx.keys_at(automerge::ROOT, target_heads).collect(); + keys.into_iter() + .filter_map(|key| { + tx.get_at(automerge::ROOT, key.as_str(), target_heads) + .ok() + .flatten() + .map(|(v, id)| (key, v.to_owned(), id)) + }) + .collect() + }; + for (key, value, source_id) in &target_entries { + put_value_into_map(tx, &automerge::ROOT, key, value, source_id, target_heads)?; + } + Ok(()) +} + +fn collect_children_map( + tx: &automerge::transaction::Transaction<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Vec<(String, automerge::Value<'static>, automerge::ObjId)> { + use automerge::ReadDoc; + let keys: Vec = tx.keys_at(source_id, heads).collect(); + keys.into_iter() + .filter_map(|key| { + tx.get_at(source_id, key.as_str(), heads) + .ok() + .flatten() + .map(|(v, id)| (key, v.to_owned(), id)) + }) + .collect() +} + +fn collect_children_list( + tx: &automerge::transaction::Transaction<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Vec<(automerge::Value<'static>, automerge::ObjId)> { + use automerge::ReadDoc; + let len = tx.length_at(source_id, heads); + (0..len) + .filter_map(|i| { + tx.get_at(source_id, i, heads) + .ok() + .flatten() + .map(|(v, id)| (v.to_owned(), id)) + }) + .collect() +} + +fn put_value_into_map<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + parent: &automerge::ObjId, + key: &str, + value: &automerge::Value<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::{ObjType, ReadDoc}; + + match value { + automerge::Value::Object(ObjType::Text) => { + let text = tx.text_at(source_id, heads)?; + let marks = tx.marks_at(source_id, heads)?; + let new_id = tx.put_object(parent, key, ObjType::Text)?; + tx.splice_text(&new_id, 0, 0, &text)?; + for mark in marks { + tx.mark(&new_id, mark, automerge::marks::ExpandMark::After)?; + } + } + automerge::Value::Object(ObjType::Map) => { + let new_id = tx.put_object(parent, key, ObjType::Map)?; + let children = collect_children_map(tx, source_id, heads); + for (child_key, child_val, child_src) in &children { + put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; + } + } + automerge::Value::Object(ObjType::List) => { + let new_id = tx.put_object(parent, key, ObjType::List)?; + let children = collect_children_list(tx, source_id, heads); + for (i, (child_val, child_src)) in children.iter().enumerate() { + insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; + } + } + automerge::Value::Object(obj_type) => { + tx.put_object(parent, key, *obj_type)?; + } + automerge::Value::Scalar(s) => { + tx.put(parent, key, s.as_ref().clone())?; + } + } + Ok(()) +} + +fn insert_value_into_list_from_doc<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + parent: &automerge::ObjId, + index: usize, + value: &automerge::Value<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::{ObjType, ReadDoc}; + + match value { + automerge::Value::Object(ObjType::Text) => { + let text = tx.text_at(source_id, heads)?; + let marks = tx.marks_at(source_id, heads)?; + let new_id = tx.insert_object(parent, index, ObjType::Text)?; + tx.splice_text(&new_id, 0, 0, &text)?; + for mark in marks { + tx.mark(&new_id, mark, automerge::marks::ExpandMark::After)?; + } + } + automerge::Value::Object(ObjType::Map) => { + let new_id = tx.insert_object(parent, index, ObjType::Map)?; + let children = collect_children_map(tx, source_id, heads); + for (child_key, child_val, child_src) in &children { + put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; + } + } + automerge::Value::Object(ObjType::List) => { + let new_id = tx.insert_object(parent, index, ObjType::List)?; + let children = collect_children_list(tx, source_id, heads); + for (i, (child_val, child_src)) in children.iter().enumerate() { + insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; + } + } + automerge::Value::Object(obj_type) => { + tx.insert_object(parent, index, *obj_type)?; + } + automerge::Value::Scalar(s) => { + tx.insert(parent, index, s.as_ref().clone())?; + } + } + Ok(()) +} + /// Convert automerge hydrate::Value to serde_json::Value. pub fn hydrate_to_json(value: &hydrate::Value) -> Value { match value { From b1c1b8823951faf519000d6555986862a4048234 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 14:45:38 +0100 Subject: [PATCH 16/38] FIX: Rich text handling in snapshots --- .../frontend/src/api/document_editing.test.ts | 244 ++++++++++++++++++ .../rich_text_editor/rich_text_editor.tsx | 41 ++- packages/notebook-types/src/automerge_json.rs | 33 ++- 3 files changed, 301 insertions(+), 17 deletions(-) diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index 9c4a44cdf..631d5c8db 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -742,4 +742,248 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual(marksAfterRevert[0]!.end, 10); }, ); + + // --------------------------------------------------------------- + // Test 13: Redo does not bleed rich text content across cells + // --------------------------------------------------------------- + test.sequential( + "should not copy rich text content to other cells on redo", + { timeout: 20000 }, + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Multi Cell Redo - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); + + const cellA = v4(); + const cellB = v4(); + + handle.change((doc) => { + doc.notebook.cellOrder.push(cellA); + doc.notebook.cellContents[cellA] = { + tag: "rich-text", + id: cellA, + content: "", + } as any; + Automerge.splice( + doc, + ["notebook", "cellContents", cellA, "content"], + 0, + 0, + "Alpha content", + ); + + doc.notebook.cellOrder.push(cellB); + doc.notebook.cellContents[cellB] = { + tag: "rich-text", + id: cellB, + content: "", + } as any; + Automerge.splice( + doc, + ["notebook", "cellContents", cellB, "content"], + 0, + 0, + "Beta content", + ); + }); + + const contentOf = (cellId: string) => + (handle.doc().notebook.cellContents[cellId] as any)?.content as + | string + | undefined; + + assert.strictEqual(contentOf(cellA), "Alpha content"); + assert.strictEqual(contentOf(cellB), "Beta content"); + + // Wait for autosave → snapshot 2 (both cells present). + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after adding cells"); + + const afterCells = findDoc(refId); + assert(afterCells); + const twoCellSnapshotId = afterCells.currentSnapshot; + + // Edit only cell A. + handle.change((doc) => { + Automerge.splice( + doc, + ["notebook", "cellContents", cellA, "content"], + 0, + 13, + "Alpha edited!", + ); + }); + + assert.strictEqual(contentOf(cellA), "Alpha edited!"); + assert.strictEqual(contentOf(cellB), "Beta content"); + + // Wait for autosave → snapshot 3. + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 3; + }, "Should have three snapshots after editing cell A"); + + const afterEdit = findDoc(refId); + assert(afterEdit); + const editedSnapshotId = afterEdit.currentSnapshot; + + // Undo: navigate back to snapshot 2 (before cell A edit). + unwrap(await rpc.set_current_snapshot.mutate(refId, twoCellSnapshotId)); + await waitFor( + () => contentOf(cellA) === "Alpha content", + "Undo should revert cell A to original", + ); + + assert.strictEqual( + contentOf(cellA), + "Alpha content", + "After undo, cell A should have original content", + ); + assert.strictEqual( + contentOf(cellB), + "Beta content", + "After undo, cell B should still have its own content", + ); + + // Redo: navigate forward to snapshot 3 (cell A edited). + unwrap(await rpc.set_current_snapshot.mutate(refId, editedSnapshotId)); + await waitFor( + () => contentOf(cellA) === "Alpha edited!", + "Redo should restore cell A edit", + ); + + assert.strictEqual( + contentOf(cellA), + "Alpha edited!", + "After redo, cell A should have edited content", + ); + assert.strictEqual( + contentOf(cellB), + "Beta content", + `After redo, cell B must keep its own content, ` + + `but got "${contentOf(cellB)}"`, + ); + }, + ); + + // --------------------------------------------------------------- + // Test 14: Block markers survive snapshot navigation (no U+FFFC leak) + // --------------------------------------------------------------- + test.sequential( + "should preserve block markers as structural elements after undo", + { timeout: 15000 }, + async () => { + await signInWithEmailAndPassword(auth, email, password); + + const name = `Block Markers - ${v4()}`; + const refId = await createDoc(name); + + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); + + const initialDoc = findDoc(refId); + assert(initialDoc); + + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); + + const cellId = v4(); + const contentPath = ["notebook", "cellContents", cellId, "content"]; + + handle.change((doc) => { + doc.notebook.cellOrder.push(cellId); + doc.notebook.cellContents[cellId] = { + tag: "rich-text", + id: cellId, + content: "", + } as any; + Automerge.splitBlock(doc, contentPath, 0, { + type: "paragraph", + }); + Automerge.splice(doc, contentPath, 1, 0, "hello 1"); + }); + + const spansBefore = Automerge.spans(handle.doc(), contentPath as any); + assert.strictEqual(spansBefore.length, 2, "Should have block + text span"); + assert.strictEqual( + spansBefore[0]!.type, + "block", + "First span should be a block marker", + ); + assert.strictEqual( + spansBefore[1]!.type, + "text", + "Second span should be text", + ); + assert.strictEqual(spansBefore[1]!.value, "hello 1"); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after adding cell with block marker"); + + const afterEdit = findDoc(refId); + assert(afterEdit); + const withBlockSnapshotId = afterEdit.currentSnapshot; + + // Make another edit to create a third snapshot. + handle.change((doc) => { + doc.name = `Block Markers Edited - ${v4()}`; + }); + + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 3; + }, "Should have three snapshots"); + + // Undo: navigate back to the snapshot with the block marker. + unwrap(await rpc.set_current_snapshot.mutate(refId, withBlockSnapshotId)); + await waitFor( + () => handle.doc().name === name, + `Should revert to original name`, + ); + + // The critical check: spans must still be structural, not + // literal U+FFFC characters in the text. + const spansAfter = Automerge.spans(handle.doc(), contentPath as any); + assert.strictEqual( + spansAfter.length, + 2, + `Should still have 2 spans (block + text) after undo, ` + + `but got ${JSON.stringify(spansAfter)}`, + ); + assert.strictEqual( + spansAfter[0]!.type, + "block", + `First span should be a block marker after undo, ` + + `but got ${JSON.stringify(spansAfter[0])}`, + ); + assert.strictEqual(spansAfter[1]!.type, "text"); + assert.strictEqual( + spansAfter[1]!.value, + "hello 1", + `Text should be 'hello 1' without U+FFFC chars, ` + + `but got ${JSON.stringify(spansAfter[1]!.value)}`, + ); + }, + ); }); diff --git a/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx b/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx index a1839a014..a289bd52d 100644 --- a/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx +++ b/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx @@ -1,5 +1,5 @@ -import type { Prop } from "@automerge/automerge"; -import type { DocHandle } from "@automerge/automerge-repo"; +import type { Patch, Prop } from "@automerge/automerge"; +import type { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"; import { makeBlockMathInputRule, mathBackspaceCmd, @@ -125,11 +125,14 @@ export const RichTextEditor = ( const [headingLevel, setHeadingLevel] = createSignal(null); const isReady = useDocHandleReady(() => props.handle); + const [reinitTrigger, setReinitTrigger] = createSignal(0); + createEffect(() => { // NOTE: Make the effect depend on the given ID to ensure that this // component updates when the Automerge handle and path both stay the // same but the path refers to a different object in the document. void props.id; + void reinitTrigger(); if (!isReady()) { return; @@ -277,7 +280,17 @@ export const RichTextEditor = ( }, }); - onCleanup(() => view.destroy()); + const onRemoteChange = ({ patches }: DocHandleChangePayload) => { + if (hasStructuralReplacement(patches, props.path)) { + setReinitTrigger((c) => c + 1); + } + }; + props.handle.on("change", onRemoteChange); + + onCleanup(() => { + props.handle.off("change", onRemoteChange); + view.destroy(); + }); }); return ( @@ -476,3 +489,25 @@ function TooltipButton(props: { ); } + +/** True when `candidate` is a prefix of (or equal to) `target`. */ +function isPathPrefixOf(candidate: Prop[], target: Prop[]): boolean { + if (candidate.length > target.length) return false; + for (let i = 0; i < candidate.length; i++) { + if (candidate[i] !== target[i]) return false; + } + return true; +} + +/** + * Detect patches that indicate structural replacement of the text object or + * one of its ancestors. `@automerge/prosemirror`'s `gatherPatches` skips these, + * leaving the ProseMirror document out of sync with the Automerge state. + */ +function hasStructuralReplacement(patches: Patch[], textPath: Prop[]): boolean { + return patches.some( + (p) => + (p.action === "put" || p.action === "del") && + isPathPrefixOf(p.path, textPath), + ); +} diff --git a/packages/notebook-types/src/automerge_json.rs b/packages/notebook-types/src/automerge_json.rs index 38167c809..92da71bbe 100644 --- a/packages/notebook-types/src/automerge_json.rs +++ b/packages/notebook-types/src/automerge_json.rs @@ -184,6 +184,21 @@ fn collect_children_list( .collect() } +fn copy_text_spans<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + new_id: &automerge::ObjId, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ReadDoc; + let spans: Vec = tx.spans_at(source_id, heads)?.collect(); + tx.update_spans( + new_id, + automerge::marks::UpdateSpansConfig::default(), + spans, + ) +} + fn put_value_into_map<'a>( tx: &mut automerge::transaction::Transaction<'a>, parent: &automerge::ObjId, @@ -192,17 +207,12 @@ fn put_value_into_map<'a>( source_id: &automerge::ObjId, heads: &[automerge::ChangeHash], ) -> Result<(), automerge::AutomergeError> { - use automerge::{ObjType, ReadDoc}; + use automerge::ObjType; match value { automerge::Value::Object(ObjType::Text) => { - let text = tx.text_at(source_id, heads)?; - let marks = tx.marks_at(source_id, heads)?; let new_id = tx.put_object(parent, key, ObjType::Text)?; - tx.splice_text(&new_id, 0, 0, &text)?; - for mark in marks { - tx.mark(&new_id, mark, automerge::marks::ExpandMark::After)?; - } + copy_text_spans(tx, &new_id, source_id, heads)?; } automerge::Value::Object(ObjType::Map) => { let new_id = tx.put_object(parent, key, ObjType::Map)?; @@ -236,17 +246,12 @@ fn insert_value_into_list_from_doc<'a>( source_id: &automerge::ObjId, heads: &[automerge::ChangeHash], ) -> Result<(), automerge::AutomergeError> { - use automerge::{ObjType, ReadDoc}; + use automerge::ObjType; match value { automerge::Value::Object(ObjType::Text) => { - let text = tx.text_at(source_id, heads)?; - let marks = tx.marks_at(source_id, heads)?; let new_id = tx.insert_object(parent, index, ObjType::Text)?; - tx.splice_text(&new_id, 0, 0, &text)?; - for mark in marks { - tx.mark(&new_id, mark, automerge::marks::ExpandMark::After)?; - } + copy_text_spans(tx, &new_id, source_id, heads)?; } automerge::Value::Object(ObjType::Map) => { let new_id = tx.insert_object(parent, index, ObjType::Map)?; From c2564ae27a14c4df4ade453b79cd54b9d4d90908 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 17:15:37 +0100 Subject: [PATCH 17/38] ENH: Add keyboard shortcuts for undo/redo --- packages/frontend/src/page/document_page.tsx | 25 +++- .../frontend/src/page/history_sidebar.tsx | 116 ++---------------- .../frontend/src/page/use_snapshot_history.ts | 115 +++++++++++++++++ .../src/history_navigator.stories.tsx | 11 +- 4 files changed, 155 insertions(+), 112 deletions(-) create mode 100644 packages/frontend/src/page/use_snapshot_history.ts diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index cbb997a2a..0309984c9 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -5,6 +5,7 @@ import ChevronsRight from "lucide-solid/icons/chevrons-right"; import History from "lucide-solid/icons/history"; import Maximize2 from "lucide-solid/icons/maximize-2"; import RotateCcw from "lucide-solid/icons/rotate-ccw"; +import { makeEventListener } from "@solid-primitives/event-listener"; import { createEffect, createMemo, @@ -35,6 +36,7 @@ import { PermissionsButton } from "../user"; import { assertExhaustive } from "../util/assert_exhaustive"; import { DocumentSidebar } from "./document_page_sidebar"; import { HistorySidebar } from "./history_sidebar"; +import { useSnapshotHistory } from "./use_snapshot_history"; import "./document_page.css"; @@ -394,6 +396,27 @@ export function DocumentPane(props: { const canRestore = () => props.docRef.permissions.user === "Own"; + const history = useSnapshotHistory(() => props.docRef.refId); + + makeEventListener(window, "keydown", (evt) => { + const mod = evt.metaKey || evt.ctrlKey; + if (!mod || evt.altKey) return; + + if (evt.key === "z" || evt.key === "Z") { + if (evt.shiftKey) { + if (history.canRedo()) { + evt.preventDefault(); + history.onRedo(); + } + } else { + if (history.canUndo()) { + evt.preventDefault(); + history.onUndo(); + } + } + } + }); + return (
@@ -456,7 +479,7 @@ export function DocumentPane(props: {
- +
diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx index 56773c90b..2a0a0353c 100644 --- a/packages/frontend/src/page/history_sidebar.tsx +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -1,114 +1,18 @@ -import { createMemo } from "solid-js"; +import { HistoryNavigator } from "catcolab-ui-components"; -import { HistoryNavigator, type HistoryItem } from "catcolab-ui-components"; -import type { SnapshotInfo } from "catcolab-api/src/user_state"; +import { type SnapshotHistory, useSnapshotHistory } from "./use_snapshot_history"; -import { useApi } from "../api"; -import { useUserState } from "../user/user_state_context"; - -/** Walk backwards from `head` to root, then forward via newest children to the tip. */ -function buildFullChain( - head: string, - snapshots: { [key: string]: SnapshotInfo | undefined }, -): string[] { - const backwards: string[] = []; - let current: string | null = head; - while (current != null && snapshots[current] != null) { - backwards.push(current); - const parent: number | null = snapshots[current]!.parent ?? null; - current = parent != null ? String(parent) : null; - } - backwards.reverse(); - - let tip: string | null = newestChild(head, snapshots); - while (tip != null) { - backwards.push(tip); - tip = newestChild(tip, snapshots); - } - - return backwards; -} - -function newestChild( - snapshotId: string, - snapshots: { [key: string]: SnapshotInfo | undefined }, -): string | null { - let best: string | null = null; - let bestTime = -Infinity; - const numericId = Number.parseInt(snapshotId, 10); - for (const [id, entry] of Object.entries(snapshots)) { - if (entry != null && entry.parent === numericId && entry.createdAt > bestTime) { - best = id; - bestTime = entry.createdAt; - } - } - return best; -} - -function chainToItems( - chain: string[], - head: string, - snapshots: { [key: string]: SnapshotInfo | undefined }, -): HistoryItem[] { - const items: HistoryItem[] = []; - for (let i = chain.length - 1; i >= 0; i--) { - const id = chain[i]!; - const entry = snapshots[id]; - if (entry) { - items.push({ id, createdAt: entry.createdAt, active: id === head }); - } - } - return items; -} - -export function HistorySidebar(props: { refId: string }) { - const api = useApi(); - const userState = useUserState(); - - const docInfo = createMemo(() => userState.documents[props.refId]); - const head = createMemo(() => { - const cs = docInfo()?.currentSnapshot; - return cs != null ? String(cs) : ""; - }); - const snapshots = createMemo(() => docInfo()?.snapshots ?? {}); - - const chain = createMemo(() => { - const h = head(); - return h ? buildFullChain(h, snapshots()) : []; - }); - - const items = createMemo(() => chainToItems(chain(), head(), snapshots())); - - const currentIndex = createMemo(() => chain().indexOf(head())); - const canUndo = createMemo(() => currentIndex() > 0); - const canRedo = createMemo(() => newestChild(head(), snapshots()) != null); - - const navigate = (snapshotId: string) => { - const id = Number.parseInt(snapshotId, 10); - if (!Number.isNaN(id)) { - void api.rpc.set_current_snapshot.mutate(props.refId, id); - } - }; - - const onUndo = () => { - const idx = currentIndex(); - const prev = chain()[idx - 1]; - if (idx > 0 && prev != null) navigate(prev); - }; - - const onRedo = () => { - const child = newestChild(head(), snapshots()); - if (child != null) navigate(child); - }; +export function HistorySidebar(props: { refId: string; history?: SnapshotHistory }) { + const history = props.history ?? useSnapshotHistory(() => props.refId); return ( ); } diff --git a/packages/frontend/src/page/use_snapshot_history.ts b/packages/frontend/src/page/use_snapshot_history.ts new file mode 100644 index 000000000..b594a916d --- /dev/null +++ b/packages/frontend/src/page/use_snapshot_history.ts @@ -0,0 +1,115 @@ +import { type Accessor, createMemo } from "solid-js"; + +import type { HistoryItem } from "catcolab-ui-components"; +import type { SnapshotInfo } from "catcolab-api/src/user_state"; + +import { useApi } from "../api"; +import { useUserState } from "../user/user_state_context"; + +/** Walk backwards from `head` to root, then forward via newest children to the tip. */ +function buildFullChain( + head: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): string[] { + const backwards: string[] = []; + let current: string | null = head; + while (current != null && snapshots[current] != null) { + backwards.push(current); + const parent: number | null = snapshots[current]!.parent ?? null; + current = parent != null ? String(parent) : null; + } + backwards.reverse(); + + let tip: string | null = newestChild(head, snapshots); + while (tip != null) { + backwards.push(tip); + tip = newestChild(tip, snapshots); + } + + return backwards; +} + +function newestChild( + snapshotId: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): string | null { + let best: string | null = null; + let bestTime = -Infinity; + const numericId = Number.parseInt(snapshotId, 10); + for (const [id, entry] of Object.entries(snapshots)) { + if (entry != null && entry.parent === numericId && entry.createdAt > bestTime) { + best = id; + bestTime = entry.createdAt; + } + } + return best; +} + +function chainToItems( + chain: string[], + head: string, + snapshots: { [key: string]: SnapshotInfo | undefined }, +): HistoryItem[] { + const items: HistoryItem[] = []; + for (let i = chain.length - 1; i >= 0; i--) { + const id = chain[i]!; + const entry = snapshots[id]; + if (entry) { + items.push({ id, createdAt: entry.createdAt, active: id === head }); + } + } + return items; +} + +export type SnapshotHistory = { + items: Accessor; + canUndo: Accessor; + canRedo: Accessor; + onUndo: () => void; + onRedo: () => void; + navigate: (snapshotId: string) => void; +}; + +/** Reactive hook providing snapshot history navigation for a document ref. */ +export function useSnapshotHistory(refId: Accessor): SnapshotHistory { + const api = useApi(); + const userState = useUserState(); + + const docInfo = createMemo(() => userState.documents[refId()]); + const head = createMemo(() => { + const cs = docInfo()?.currentSnapshot; + return cs != null ? String(cs) : ""; + }); + const snapshots = createMemo(() => docInfo()?.snapshots ?? {}); + + const chain = createMemo(() => { + const h = head(); + return h ? buildFullChain(h, snapshots()) : []; + }); + + const items = createMemo(() => chainToItems(chain(), head(), snapshots())); + + const currentIndex = createMemo(() => chain().indexOf(head())); + const canUndo = createMemo(() => currentIndex() > 0); + const canRedo = createMemo(() => newestChild(head(), snapshots()) != null); + + const navigate = (snapshotId: string) => { + const id = Number.parseInt(snapshotId, 10); + if (!Number.isNaN(id)) { + void api.rpc.set_current_snapshot.mutate(refId(), id); + } + }; + + const onUndo = () => { + const idx = currentIndex(); + const prev = chain()[idx - 1]; + if (idx > 0 && prev != null) navigate(prev); + }; + + const onRedo = () => { + const child = newestChild(head(), snapshots()); + if (child != null) navigate(child); + }; + + return { items, canUndo, canRedo, onUndo, onRedo, navigate }; +} diff --git a/packages/ui-components/src/history_navigator.stories.tsx b/packages/ui-components/src/history_navigator.stories.tsx index e5071ec5a..f28696856 100644 --- a/packages/ui-components/src/history_navigator.stories.tsx +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -75,10 +75,7 @@ function makeInitialHistory(): Record { return Object.fromEntries(initialEntries); } -function newestChild( - snapshotId: string, - history: Record, -): string | null { +function newestChild(snapshotId: string, history: Record): string | null { let best: string | null = null; let bestTime = -Infinity; for (const [id, e] of Object.entries(history)) { @@ -108,7 +105,11 @@ function buildFullChain(head: string, history: Record): st return backwards; } -function chainToItems(chain: string[], head: string, history: Record): HistoryItem[] { +function chainToItems( + chain: string[], + head: string, + history: Record, +): HistoryItem[] { const items: HistoryItem[] = []; for (let i = chain.length - 1; i >= 0; i--) { const id = chain[i]!; From 123c886d2056cfecf21093cda2b76a0ca921d390 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 17:16:20 +0100 Subject: [PATCH 18/38] ENH: Improve history_navigator display with "X ... ago" --- .../src/history_navigator.stories.tsx | 81 +++++++++++++++++++ .../ui-components/src/history_navigator.tsx | 58 ++++++++++--- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/ui-components/src/history_navigator.stories.tsx b/packages/ui-components/src/history_navigator.stories.tsx index f28696856..af27659c1 100644 --- a/packages/ui-components/src/history_navigator.stories.tsx +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -192,4 +192,85 @@ function InteractiveStory(props: { initialHead: string }) { export const Default: Story = { render: () => , + tags: ["!autodocs", "!dev"], +}; + +// --------------------------------------------------------------------------- +// Static stories showing how various time ranges render. +// --------------------------------------------------------------------------- + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function staticItems(offsets: { label: string; ago: number }[]): HistoryItem[] { + const now = Date.now(); + return offsets.map((o, i) => ({ + id: `${i}`, + createdAt: now - o.ago, + active: i === 0, + })); +} + +function StaticStory(props: { items: HistoryItem[] }) { + return ( +
+ {}} + onRedo={() => {}} + onSelect={() => {}} + /> +
+ ); +} + +/** Entries spanning minutes, hours, days, months, and previous years. */ +export const MixedTimeRanges: Story = { + render: () => ( + + ), +}; + +/** Many snapshots in one minute (~90 min ago) to exercise duplicate-minute ~2, ~3 suffixes. */ +export const ManyChangesOneHourThirtyAgo: Story = { + render: () => { + const now = Date.now(); + const target = now - (90 * MINUTE + 15 * 1000); + const minuteStart = Math.floor(target / 60_000) * 60_000; + const n = 10; + const items: HistoryItem[] = []; + for (let i = 0; i < n; i++) { + const offsetInMinute = (n - i) * 5000; + items.push({ + id: String(i), + createdAt: minuteStart + offsetInMinute, + active: i === 0, + }); + } + return ; + }, }; diff --git a/packages/ui-components/src/history_navigator.tsx b/packages/ui-components/src/history_navigator.tsx index 1ffc773cd..d6d2b64e2 100644 --- a/packages/ui-components/src/history_navigator.tsx +++ b/packages/ui-components/src/history_navigator.tsx @@ -1,6 +1,6 @@ import Redo2 from "lucide-solid/icons/redo-2"; import Undo2 from "lucide-solid/icons/undo-2"; -import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { IconButton } from "./icon_button"; import styles from "./history_navigator.module.css"; @@ -22,29 +22,66 @@ export type HistoryNavigatorProps = { onSelect: (id: string) => void; }; -function formatTimestamp(ms: number): string { +function formatRelativeTime(ms: number, now: number): string { + const diffMs = now - ms; + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + if (minutes < 120) { + const remainMin = minutes % 60; + return remainMin === 0 ? "1 hour ago" : `1 hour ${remainMin} min ago`; + } + if (hours < 24) { + const hourLabel = hours === 1 ? "hour" : "hours"; + return `${hours} ${hourLabel} ago`; + } + if (days === 1) return "yesterday"; + if (days < 7) return `${days} days ago`; + const d = new Date(ms); - const date = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); - const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); - return `${date}, ${time}`; + return d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatExactTimestamp(ms: number): string { + const d = new Date(ms); + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); } const ROW_HEIGHT = 44; /** Panel for navigating document snapshot history with undo/redo and a scrollable list. */ export function HistoryNavigator(props: HistoryNavigatorProps) { + const [now, setNow] = createSignal(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 30_000); + onCleanup(() => clearInterval(timer)); + const displayItems = createMemo(() => { + const currentNow = now(); const raw = props.items.map((item) => ({ ...item, - minuteKey: formatTimestamp(item.createdAt), + minuteKey: Math.floor(item.createdAt / 60_000), })); - const countPerMinute = new Map(); + const countPerMinute = new Map(); for (const item of raw) { countPerMinute.set(item.minuteKey, (countPerMinute.get(item.minuteKey) ?? 0) + 1); } - const indexPerMinute = new Map(); + const indexPerMinute = new Map(); const suffixByIndex = new Map(); for (let i = raw.length - 1; i >= 0; i--) { const item = raw[i]!; @@ -61,7 +98,8 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { return raw.map((item, i) => ({ id: item.id, active: item.active, - timestamp: item.minuteKey, + timestamp: formatRelativeTime(item.createdAt, currentNow), + exactTimestamp: formatExactTimestamp(item.createdAt), suffix: suffixByIndex.get(i) ?? null, })); }); @@ -148,7 +186,7 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { - + {item.timestamp} {item.suffix} From 08b350a441d75e565d0b6e965d9e86c9056fccc7 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 17:19:42 +0100 Subject: [PATCH 19/38] ENH: Show shortcuts on undo/redo buttons --- .../frontend/src/page/history_sidebar.tsx | 5 ++++ .../src/history_navigator.stories.tsx | 23 ++++++++++++++++++- .../ui-components/src/history_navigator.tsx | 8 +++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx index 2a0a0353c..3aa7b2e75 100644 --- a/packages/frontend/src/page/history_sidebar.tsx +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -2,6 +2,9 @@ import { HistoryNavigator } from "catcolab-ui-components"; import { type SnapshotHistory, useSnapshotHistory } from "./use_snapshot_history"; +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent); +const mod = isMac ? "\u2318" : "Ctrl+"; + export function HistorySidebar(props: { refId: string; history?: SnapshotHistory }) { const history = props.history ?? useSnapshotHistory(() => props.refId); @@ -13,6 +16,8 @@ export function HistorySidebar(props: { refId: string; history?: SnapshotHistory onUndo={history.onUndo} onRedo={history.onRedo} onSelect={history.navigate} + undoTooltip={`Undo (${mod}Z)`} + redoTooltip={`Redo (${mod}Shift+Z)`} /> ); } diff --git a/packages/ui-components/src/history_navigator.stories.tsx b/packages/ui-components/src/history_navigator.stories.tsx index af27659c1..b76ad57f4 100644 --- a/packages/ui-components/src/history_navigator.stories.tsx +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -212,7 +212,11 @@ function staticItems(offsets: { label: string; ago: number }[]): HistoryItem[] { })); } -function StaticStory(props: { items: HistoryItem[] }) { +function StaticStory(props: { + items: HistoryItem[]; + undoTooltip?: string; + redoTooltip?: string; +}) { return (
{}} onRedo={() => {}} onSelect={() => {}} + undoTooltip={props.undoTooltip} + redoTooltip={props.redoTooltip} />
); @@ -255,6 +261,21 @@ export const MixedTimeRanges: Story = { ), }; +/** Custom tooltips showing keyboard shortcuts on the undo/redo buttons. */ +export const WithShortcutTooltips: Story = { + render: () => ( + + ), +}; + /** Many snapshots in one minute (~90 min ago) to exercise duplicate-minute ~2, ~3 suffixes. */ export const ManyChangesOneHourThirtyAgo: Story = { render: () => { diff --git a/packages/ui-components/src/history_navigator.tsx b/packages/ui-components/src/history_navigator.tsx index d6d2b64e2..f3f619588 100644 --- a/packages/ui-components/src/history_navigator.tsx +++ b/packages/ui-components/src/history_navigator.tsx @@ -20,6 +20,10 @@ export type HistoryNavigatorProps = { onUndo: () => void; onRedo: () => void; onSelect: (id: string) => void; + /** Tooltip for the undo button. Defaults to "Undo". */ + undoTooltip?: string; + /** Tooltip for the redo button. Defaults to "Redo". */ + redoTooltip?: string; }; function formatRelativeTime(ms: number, now: number): string { @@ -151,10 +155,10 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { return (
- + - +
From 8976a1fa0f72777d19e1cc75922e569778d5404f Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 19:27:28 +0100 Subject: [PATCH 20/38] REFACTOR: Split out automerge_util from automerge_json --- packages/backend/src/document.rs | 5 +- packages/notebook-types/src/automerge_json.rs | 160 ------------------ packages/notebook-types/src/automerge_util.rs | 156 +++++++++++++++++ packages/notebook-types/src/lib.rs | 3 + 4 files changed, 161 insertions(+), 163 deletions(-) create mode 100644 packages/notebook-types/src/automerge_util.rs diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 43bb82543..a2984802f 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -4,9 +4,8 @@ use crate::app::{AppCtx, AppError, AppState}; use crate::autosave::ensure_autosave_listener; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; -use notebook_types::automerge_json::{ - copy_doc_at_heads, hydrate_to_json, populate_automerge_from_json, -}; +use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; +use notebook_types::automerge_util::copy_doc_at_heads; use samod::DocumentId; use serde_json::Value; use uuid::Uuid; diff --git a/packages/notebook-types/src/automerge_json.rs b/packages/notebook-types/src/automerge_json.rs index 92da71bbe..65e08b9bd 100644 --- a/packages/notebook-types/src/automerge_json.rs +++ b/packages/notebook-types/src/automerge_json.rs @@ -117,166 +117,6 @@ pub fn populate_automerge_from_json<'a>( Ok(()) } -/// Overwrite the document root with the state at `target_heads`. -/// -/// Unlike the `hydrate_to_json` + `populate_automerge_from_json` round-trip, -/// this preserves rich-text marks and block markers on Text objects by using -/// `marks_at` / `mark` instead of going through a plain-string intermediary. -pub fn copy_doc_at_heads<'a>( - tx: &mut automerge::transaction::Transaction<'a>, - target_heads: &[automerge::ChangeHash], -) -> Result<(), automerge::AutomergeError> { - use automerge::ReadDoc; - - let current_keys: Vec = tx.keys(automerge::ROOT).collect(); - for key in ¤t_keys { - tx.delete(automerge::ROOT, key.as_str())?; - } - - let target_entries: Vec<_> = { - let keys: Vec = tx.keys_at(automerge::ROOT, target_heads).collect(); - keys.into_iter() - .filter_map(|key| { - tx.get_at(automerge::ROOT, key.as_str(), target_heads) - .ok() - .flatten() - .map(|(v, id)| (key, v.to_owned(), id)) - }) - .collect() - }; - for (key, value, source_id) in &target_entries { - put_value_into_map(tx, &automerge::ROOT, key, value, source_id, target_heads)?; - } - Ok(()) -} - -fn collect_children_map( - tx: &automerge::transaction::Transaction<'_>, - source_id: &automerge::ObjId, - heads: &[automerge::ChangeHash], -) -> Vec<(String, automerge::Value<'static>, automerge::ObjId)> { - use automerge::ReadDoc; - let keys: Vec = tx.keys_at(source_id, heads).collect(); - keys.into_iter() - .filter_map(|key| { - tx.get_at(source_id, key.as_str(), heads) - .ok() - .flatten() - .map(|(v, id)| (key, v.to_owned(), id)) - }) - .collect() -} - -fn collect_children_list( - tx: &automerge::transaction::Transaction<'_>, - source_id: &automerge::ObjId, - heads: &[automerge::ChangeHash], -) -> Vec<(automerge::Value<'static>, automerge::ObjId)> { - use automerge::ReadDoc; - let len = tx.length_at(source_id, heads); - (0..len) - .filter_map(|i| { - tx.get_at(source_id, i, heads) - .ok() - .flatten() - .map(|(v, id)| (v.to_owned(), id)) - }) - .collect() -} - -fn copy_text_spans<'a>( - tx: &mut automerge::transaction::Transaction<'a>, - new_id: &automerge::ObjId, - source_id: &automerge::ObjId, - heads: &[automerge::ChangeHash], -) -> Result<(), automerge::AutomergeError> { - use automerge::ReadDoc; - let spans: Vec = tx.spans_at(source_id, heads)?.collect(); - tx.update_spans( - new_id, - automerge::marks::UpdateSpansConfig::default(), - spans, - ) -} - -fn put_value_into_map<'a>( - tx: &mut automerge::transaction::Transaction<'a>, - parent: &automerge::ObjId, - key: &str, - value: &automerge::Value<'_>, - source_id: &automerge::ObjId, - heads: &[automerge::ChangeHash], -) -> Result<(), automerge::AutomergeError> { - use automerge::ObjType; - - match value { - automerge::Value::Object(ObjType::Text) => { - let new_id = tx.put_object(parent, key, ObjType::Text)?; - copy_text_spans(tx, &new_id, source_id, heads)?; - } - automerge::Value::Object(ObjType::Map) => { - let new_id = tx.put_object(parent, key, ObjType::Map)?; - let children = collect_children_map(tx, source_id, heads); - for (child_key, child_val, child_src) in &children { - put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; - } - } - automerge::Value::Object(ObjType::List) => { - let new_id = tx.put_object(parent, key, ObjType::List)?; - let children = collect_children_list(tx, source_id, heads); - for (i, (child_val, child_src)) in children.iter().enumerate() { - insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; - } - } - automerge::Value::Object(obj_type) => { - tx.put_object(parent, key, *obj_type)?; - } - automerge::Value::Scalar(s) => { - tx.put(parent, key, s.as_ref().clone())?; - } - } - Ok(()) -} - -fn insert_value_into_list_from_doc<'a>( - tx: &mut automerge::transaction::Transaction<'a>, - parent: &automerge::ObjId, - index: usize, - value: &automerge::Value<'_>, - source_id: &automerge::ObjId, - heads: &[automerge::ChangeHash], -) -> Result<(), automerge::AutomergeError> { - use automerge::ObjType; - - match value { - automerge::Value::Object(ObjType::Text) => { - let new_id = tx.insert_object(parent, index, ObjType::Text)?; - copy_text_spans(tx, &new_id, source_id, heads)?; - } - automerge::Value::Object(ObjType::Map) => { - let new_id = tx.insert_object(parent, index, ObjType::Map)?; - let children = collect_children_map(tx, source_id, heads); - for (child_key, child_val, child_src) in &children { - put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; - } - } - automerge::Value::Object(ObjType::List) => { - let new_id = tx.insert_object(parent, index, ObjType::List)?; - let children = collect_children_list(tx, source_id, heads); - for (i, (child_val, child_src)) in children.iter().enumerate() { - insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; - } - } - automerge::Value::Object(obj_type) => { - tx.insert_object(parent, index, *obj_type)?; - } - automerge::Value::Scalar(s) => { - tx.insert(parent, index, s.as_ref().clone())?; - } - } - Ok(()) -} - /// Convert automerge hydrate::Value to serde_json::Value. pub fn hydrate_to_json(value: &hydrate::Value) -> Value { match value { diff --git a/packages/notebook-types/src/automerge_util.rs b/packages/notebook-types/src/automerge_util.rs new file mode 100644 index 000000000..91d6c62c6 --- /dev/null +++ b/packages/notebook-types/src/automerge_util.rs @@ -0,0 +1,156 @@ +//! Utilities for copying Automerge document state between heads. + +use automerge::transaction::Transactable; + +/// Overwrite the document root with the state at `target_heads`. +/// +/// Unlike the `hydrate_to_json` + `populate_automerge_from_json` round-trip, +/// this preserves rich-text marks and block markers on Text objects by using +/// `marks_at` / `mark` instead of going through a plain-string intermediary. +pub fn copy_doc_at_heads<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + target_heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ReadDoc; + + let current_keys: Vec = tx.keys(automerge::ROOT).collect(); + for key in ¤t_keys { + tx.delete(automerge::ROOT, key.as_str())?; + } + + let target_entries: Vec<_> = { + let keys: Vec = tx.keys_at(automerge::ROOT, target_heads).collect(); + keys.into_iter() + .filter_map(|key| { + tx.get_at(automerge::ROOT, key.as_str(), target_heads) + .ok() + .flatten() + .map(|(v, id)| (key, v.to_owned(), id)) + }) + .collect() + }; + for (key, value, source_id) in &target_entries { + put_value_into_map(tx, &automerge::ROOT, key, value, source_id, target_heads)?; + } + Ok(()) +} + +fn collect_children_map( + tx: &automerge::transaction::Transaction<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Vec<(String, automerge::Value<'static>, automerge::ObjId)> { + use automerge::ReadDoc; + let keys: Vec = tx.keys_at(source_id, heads).collect(); + keys.into_iter() + .filter_map(|key| { + tx.get_at(source_id, key.as_str(), heads) + .ok() + .flatten() + .map(|(v, id)| (key, v.to_owned(), id)) + }) + .collect() +} + +fn collect_children_list( + tx: &automerge::transaction::Transaction<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Vec<(automerge::Value<'static>, automerge::ObjId)> { + use automerge::ReadDoc; + let len = tx.length_at(source_id, heads); + (0..len) + .filter_map(|i| { + tx.get_at(source_id, i, heads).ok().flatten().map(|(v, id)| (v.to_owned(), id)) + }) + .collect() +} + +fn copy_text_spans<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + new_id: &automerge::ObjId, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ReadDoc; + let spans: Vec = tx.spans_at(source_id, heads)?.collect(); + tx.update_spans(new_id, automerge::marks::UpdateSpansConfig::default(), spans) +} + +fn put_value_into_map<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + parent: &automerge::ObjId, + key: &str, + value: &automerge::Value<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ObjType; + + match value { + automerge::Value::Object(ObjType::Text) => { + let new_id = tx.put_object(parent, key, ObjType::Text)?; + copy_text_spans(tx, &new_id, source_id, heads)?; + } + automerge::Value::Object(ObjType::Map) => { + let new_id = tx.put_object(parent, key, ObjType::Map)?; + let children = collect_children_map(tx, source_id, heads); + for (child_key, child_val, child_src) in &children { + put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; + } + } + automerge::Value::Object(ObjType::List) => { + let new_id = tx.put_object(parent, key, ObjType::List)?; + let children = collect_children_list(tx, source_id, heads); + for (i, (child_val, child_src)) in children.iter().enumerate() { + insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; + } + } + automerge::Value::Object(obj_type) => { + tx.put_object(parent, key, *obj_type)?; + } + automerge::Value::Scalar(s) => { + tx.put(parent, key, s.as_ref().clone())?; + } + } + Ok(()) +} + +fn insert_value_into_list_from_doc<'a>( + tx: &mut automerge::transaction::Transaction<'a>, + parent: &automerge::ObjId, + index: usize, + value: &automerge::Value<'_>, + source_id: &automerge::ObjId, + heads: &[automerge::ChangeHash], +) -> Result<(), automerge::AutomergeError> { + use automerge::ObjType; + + match value { + automerge::Value::Object(ObjType::Text) => { + let new_id = tx.insert_object(parent, index, ObjType::Text)?; + copy_text_spans(tx, &new_id, source_id, heads)?; + } + automerge::Value::Object(ObjType::Map) => { + let new_id = tx.insert_object(parent, index, ObjType::Map)?; + let children = collect_children_map(tx, source_id, heads); + for (child_key, child_val, child_src) in &children { + put_value_into_map(tx, &new_id, child_key, child_val, child_src, heads)?; + } + } + automerge::Value::Object(ObjType::List) => { + let new_id = tx.insert_object(parent, index, ObjType::List)?; + let children = collect_children_list(tx, source_id, heads); + for (i, (child_val, child_src)) in children.iter().enumerate() { + insert_value_into_list_from_doc(tx, &new_id, i, child_val, child_src, heads)?; + } + } + automerge::Value::Object(obj_type) => { + tx.insert_object(parent, index, *obj_type)?; + } + automerge::Value::Scalar(s) => { + tx.insert(parent, index, s.as_ref().clone())?; + } + } + Ok(()) +} diff --git a/packages/notebook-types/src/lib.rs b/packages/notebook-types/src/lib.rs index 83ff74c14..88300404b 100644 --- a/packages/notebook-types/src/lib.rs +++ b/packages/notebook-types/src/lib.rs @@ -9,6 +9,9 @@ pub mod v1; #[cfg(feature = "backend")] pub mod automerge_json; +#[cfg(feature = "backend")] +pub mod automerge_util; + #[cfg(test)] mod test_utils; From aef48ef537a220a095da9b6e574e280ad8f3ca2d Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 19:35:16 +0100 Subject: [PATCH 21/38] TEST: automerge_util --- packages/notebook-types/src/automerge_util.rs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/packages/notebook-types/src/automerge_util.rs b/packages/notebook-types/src/automerge_util.rs index 91d6c62c6..366bcced4 100644 --- a/packages/notebook-types/src/automerge_util.rs +++ b/packages/notebook-types/src/automerge_util.rs @@ -154,3 +154,224 @@ fn insert_value_into_list_from_doc<'a>( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::automerge_json::{hydrate_to_json, populate_automerge_from_json}; + use automerge::{Automerge, ObjType, ReadDoc}; + use serde_json::json; + + /// Helper: create doc populated from JSON. + fn doc_from_json(value: &serde_json::Value) -> Automerge { + let mut doc = Automerge::new(); + doc.transact(|tx| { + populate_automerge_from_json(tx, automerge::ROOT, value).unwrap(); + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + doc + } + + /// Helper: read the current doc state back as JSON. + fn doc_to_json(doc: &Automerge) -> serde_json::Value { + let value = doc.hydrate(None); + hydrate_to_json(&value) + } + + #[test] + fn copy_restores_scalar_fields() { + let mut doc = doc_from_json(&json!({ + "name": "alice", + "age": 30, + "active": true + })); + let heads_v1 = doc.get_heads(); + + // Mutate the doc to a different state. + doc.transact(|tx| { + let name_id = tx.put_object(automerge::ROOT, "name", ObjType::Text)?; + tx.splice_text(&name_id, 0, 0, "bob")?; + tx.put(automerge::ROOT, "age", 99_i64)?; + tx.put(automerge::ROOT, "active", false)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Restore to v1. + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_v1)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let result = doc_to_json(&doc); + assert_eq!(result["name"], "alice"); + assert_eq!(result["age"], 30); + assert_eq!(result["active"], true); + } + + #[test] + fn copy_restores_nested_maps() { + let mut doc = doc_from_json(&json!({ + "config": { + "theme": "dark", + "settings": { + "fontSize": 14 + } + } + })); + let heads_v1 = doc.get_heads(); + + // Overwrite with different nested structure. + doc.transact(|tx| { + tx.delete(automerge::ROOT, "config")?; + let config = tx.put_object(automerge::ROOT, "config", ObjType::Map)?; + let theme = tx.put_object(&config, "theme", ObjType::Text)?; + tx.splice_text(&theme, 0, 0, "light")?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_v1)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let result = doc_to_json(&doc); + assert_eq!(result["config"]["theme"], "dark"); + assert_eq!(result["config"]["settings"]["fontSize"], 14); + } + + #[test] + fn copy_restores_lists() { + let mut doc = doc_from_json(&json!({ + "items": ["a", "b", "c"] + })); + let heads_v1 = doc.get_heads(); + + // Replace with different list. + doc.transact(|tx| { + tx.delete(automerge::ROOT, "items")?; + let list = tx.put_object(automerge::ROOT, "items", ObjType::List)?; + let x = tx.insert_object(&list, 0, ObjType::Text)?; + tx.splice_text(&x, 0, 0, "x")?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_v1)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let result = doc_to_json(&doc); + let items: Vec<&str> = result["items"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(items, vec!["a", "b", "c"]); + } + + #[test] + fn copy_removes_keys_not_in_target() { + let mut doc = doc_from_json(&json!({ + "keep": "yes" + })); + let heads_v1 = doc.get_heads(); + + // Add extra keys. + doc.transact(|tx| { + let extra = tx.put_object(automerge::ROOT, "extra", ObjType::Text)?; + tx.splice_text(&extra, 0, 0, "should be gone")?; + tx.put(automerge::ROOT, "another", 42_i64)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_v1)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let result = doc_to_json(&doc); + assert_eq!(result["keep"], "yes"); + assert!(result.get("extra").is_none()); + assert!(result.get("another").is_none()); + } + + #[test] + fn copy_preserves_rich_text_marks() { + let mut doc = Automerge::new(); + + // Create text with a bold mark. + doc.transact(|tx| { + let text_id = tx.put_object(automerge::ROOT, "content", ObjType::Text)?; + tx.splice_text(&text_id, 0, 0, "hello world")?; + tx.mark( + &text_id, + automerge::marks::Mark::new("bold".into(), true, 0, 5), + automerge::marks::ExpandMark::After, + )?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + let heads_with_mark = doc.get_heads(); + + // Overwrite with plain text. + doc.transact(|tx| { + tx.delete(automerge::ROOT, "content")?; + let text_id = tx.put_object(automerge::ROOT, "content", ObjType::Text)?; + tx.splice_text(&text_id, 0, 0, "replaced")?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Restore. + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_with_mark)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Verify text content. + let result = doc_to_json(&doc); + assert_eq!(result["content"], "hello world"); + + // Verify mark was preserved. + let (_, content_id) = doc.get(automerge::ROOT, "content").unwrap().unwrap(); + let marks = doc.marks(&content_id).unwrap(); + assert!(!marks.is_empty(), "bold mark should be preserved"); + assert_eq!(marks[0].name(), "bold"); + assert_eq!(marks[0].start, 0); + assert_eq!(marks[0].end, 5); + } + + #[test] + fn copy_works_on_empty_doc() { + let mut doc = Automerge::new(); + let heads_empty = doc.get_heads(); + + // Add some data. + doc.transact(|tx| { + tx.put(automerge::ROOT, "key", 1_i64)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Restore to empty. + doc.transact(|tx| { + copy_doc_at_heads(tx, &heads_empty)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let keys: Vec = doc.keys(automerge::ROOT).collect(); + assert!(keys.is_empty()); + } +} From a0fd828f3b23af6cdfbda3dd825cfd8f56a12028 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 20:08:07 +0100 Subject: [PATCH 22/38] CLEANUP: Run sqlx prepare & format backend --- ...5459d4a70a0349acab02a98708ff9615108c0.json | 22 ++++++ ...694a347bed1354c737976242c76ece68e8b07.json | 76 +++++++++++++++++++ ...21c26a3e407c88c441a770925478c9b73347a.json | 22 ++++++ ...1c135cfeea17d33515eecb03208450dc87b43.json | 14 ++++ ...88e215d16a67befd25dc115ee035e86493aef.json | 15 ++++ ...c98d4f259a13fb7e86b4f85aab9c61840bed7.json | 23 ++++++ packages/backend/src/rpc.rs | 2 +- packages/backend/tests/user_state_tests.rs | 5 +- 8 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json create mode 100644 packages/backend/.sqlx/query-2a55324b99c5eb1f1825a0a3515694a347bed1354c737976242c76ece68e8b07.json create mode 100644 packages/backend/.sqlx/query-3e0e7e9b7c460fac090ce9df79921c26a3e407c88c441a770925478c9b73347a.json create mode 100644 packages/backend/.sqlx/query-800b7f4b301655523dbb2fd91181c135cfeea17d33515eecb03208450dc87b43.json create mode 100644 packages/backend/.sqlx/query-bb230520e7e59609b7fb94bcda488e215d16a67befd25dc115ee035e86493aef.json create mode 100644 packages/backend/.sqlx/query-f8c5959c1d99d5d1304bc99ee76c98d4f259a13fb7e86b4f85aab9c61840bed7.json diff --git a/packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json b/packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json new file mode 100644 index 000000000..ee8a62b92 --- /dev/null +++ b/packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT doc_id FROM refs WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "doc_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0" +} diff --git a/packages/backend/.sqlx/query-2a55324b99c5eb1f1825a0a3515694a347bed1354c737976242c76ece68e8b07.json b/packages/backend/.sqlx/query-2a55324b99c5eb1f1825a0a3515694a347bed1354c737976242c76ece68e8b07.json new file mode 100644 index 000000000..0e0f3c456 --- /dev/null +++ b/packages/backend/.sqlx/query-2a55324b99c5eb1f1825a0a3515694a347bed1354c737976242c76ece68e8b07.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH\n filtered_ids AS (\n SELECT refs.id\n FROM refs\n WHERE\n -- filter by minimum permission level (read)\n get_max_permission($1, refs.id) >= 'read'::permission_level\n -- exclude public-only documents (user must have explicit permission)\n AND EXISTS (\n SELECT 1\n FROM permissions p_searcher\n WHERE\n p_searcher.object = refs.id\n AND p_searcher.subject = $1\n )\n )\n SELECT\n refs.id AS \"ref_id!\",\n snapshots.content->>'name' AS name,\n snapshots.content->>'type' AS type_name,\n snapshots.content->>'theory' AS theory,\n refs.created AS \"created_at!\",\n refs.deleted_at,\n snapshots.content AS \"content!\",\n refs.current_snapshot AS \"current_snapshot!\",\n COALESCE(\n (SELECT json_agg(json_build_object(\n 'user', p.subject,\n 'level', INITCAP(p.level::text)\n ) ORDER BY p.level DESC)\n FROM permissions p\n WHERE p.object = refs.id\n ), '[]'::json\n ) AS \"permissions!: sqlx::types::Json>\",\n COALESCE(\n (SELECT json_object_agg(\n s.id::text,\n json_build_object(\n 'parent', s.parent,\n 'createdAt', s.created_at,\n 'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h)\n )\n )\n FROM snapshots s\n WHERE s.for_ref = refs.id\n ), '{}'::json\n ) AS \"snapshots!: sqlx::types::Json>\"\n FROM filtered_ids\n JOIN refs ON refs.id = filtered_ids.id\n JOIN snapshots ON snapshots.id = refs.current_snapshot\n ORDER BY refs.created DESC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "ref_id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "type_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "theory", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "content!", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "current_snapshot!", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "permissions!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 9, + "name": "snapshots!: sqlx::types::Json>", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + null, + null, + false, + true, + false, + false, + null, + null + ] + }, + "hash": "2a55324b99c5eb1f1825a0a3515694a347bed1354c737976242c76ece68e8b07" +} diff --git a/packages/backend/.sqlx/query-3e0e7e9b7c460fac090ce9df79921c26a3e407c88c441a770925478c9b73347a.json b/packages/backend/.sqlx/query-3e0e7e9b7c460fac090ce9df79921c26a3e407c88c441a770925478c9b73347a.json new file mode 100644 index 000000000..40c7de91e --- /dev/null +++ b/packages/backend/.sqlx/query-3e0e7e9b7c460fac090ce9df79921c26a3e407c88c441a770925478c9b73347a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT content FROM snapshots\n WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "content", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3e0e7e9b7c460fac090ce9df79921c26a3e407c88c441a770925478c9b73347a" +} diff --git a/packages/backend/.sqlx/query-800b7f4b301655523dbb2fd91181c135cfeea17d33515eecb03208450dc87b43.json b/packages/backend/.sqlx/query-800b7f4b301655523dbb2fd91181c135cfeea17d33515eecb03208450dc87b43.json new file mode 100644 index 000000000..05f00d092 --- /dev/null +++ b/packages/backend/.sqlx/query-800b7f4b301655523dbb2fd91181c135cfeea17d33515eecb03208450dc87b43.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (id, created, signed_in)\n VALUES ($1, NOW(), NOW())\n ON CONFLICT (id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "800b7f4b301655523dbb2fd91181c135cfeea17d33515eecb03208450dc87b43" +} diff --git a/packages/backend/.sqlx/query-bb230520e7e59609b7fb94bcda488e215d16a67befd25dc115ee035e86493aef.json b/packages/backend/.sqlx/query-bb230520e7e59609b7fb94bcda488e215d16a67befd25dc115ee035e86493aef.json new file mode 100644 index 000000000..fab641de8 --- /dev/null +++ b/packages/backend/.sqlx/query-bb230520e7e59609b7fb94bcda488e215d16a67befd25dc115ee035e86493aef.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE refs SET current_snapshot = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "bb230520e7e59609b7fb94bcda488e215d16a67befd25dc115ee035e86493aef" +} diff --git a/packages/backend/.sqlx/query-f8c5959c1d99d5d1304bc99ee76c98d4f259a13fb7e86b4f85aab9c61840bed7.json b/packages/backend/.sqlx/query-f8c5959c1d99d5d1304bc99ee76c98d4f259a13fb7e86b4f85aab9c61840bed7.json new file mode 100644 index 000000000..36e9b7b28 --- /dev/null +++ b/packages/backend/.sqlx/query-f8c5959c1d99d5d1304bc99ee76c98d4f259a13fb7e86b4f85aab9c61840bed7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT heads FROM snapshots WHERE id = $1 AND for_ref = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "heads", + "type_info": "ByteaArray" + } + ], + "parameters": { + "Left": [ + "Int4", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f8c5959c1d99d5d1304bc99ee76c98d4f259a13fb7e86b4f85aab9c61840bed7" +} diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index bff082429..98377137e 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -7,8 +7,8 @@ use tracing::debug; use uuid::Uuid; use super::app::{AppCtx, AppError, AppState}; -use super::autosave::ensure_autosave_listener; use super::auth::{NewPermissions, PermissionLevel, Permissions}; +use super::autosave::ensure_autosave_listener; use super::user_state::get_or_create_user_state_doc; use super::{auth, document as doc, user}; diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index 61e7d69c8..d313d7115 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -1009,10 +1009,7 @@ mod integration_tests { .fetch_one(db) .await?; - snapshot_ids.insert( - ref_id_str.clone(), - (snapshot_id, doc.created_at), - ); + snapshot_ids.insert(ref_id_str.clone(), (snapshot_id, doc.created_at)); if let Some(deleted_at) = doc.deleted_at { sqlx::query!( From ee7ec72b0e942174734563a9fd5f15b9f52ea918 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 31 Mar 2026 20:29:05 +0100 Subject: [PATCH 23/38] CLEANUP: Oxlint in frontend --- .../frontend/src/api/document_editing.test.ts | 304 +++++++++--------- packages/frontend/src/api/user_state.test.ts | 6 +- .../rich_text_editor/rich_text_editor.tsx | 12 +- packages/frontend/src/page/document_page.tsx | 15 +- .../frontend/src/page/history_sidebar.tsx | 22 +- .../frontend/src/page/use_snapshot_history.ts | 11 +- .../src/history_navigator.stories.tsx | 18 +- .../ui-components/src/history_navigator.tsx | 44 ++- 8 files changed, 226 insertions(+), 206 deletions(-) diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index 631d5c8db..1878ea4fb 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -56,7 +56,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { await rpc.delete_ref.mutate(id).catch(() => {}); } await deleteUser(user); - repo.shutdown(); + void repo.shutdown(); }); unwrap(await rpc.sign_up_or_sign_in.mutate()); @@ -223,113 +223,107 @@ describe("Document editing, snapshots, and undo/redo", async () => { // --------------------------------------------------------------- // Test 5: set_current_snapshot reverts the live document content // --------------------------------------------------------------- - test.sequential( - "should revert live document content when navigating to an older snapshot", - async () => { - await signInWithEmailAndPassword(auth, email, password); + test.sequential("should revert live document content when navigating to an older snapshot", async () => { + await signInWithEmailAndPassword(auth, email, password); - const originalName = `Revert Original - ${v4()}`; - const refId = await createDoc(originalName); + const originalName = `Revert Original - ${v4()}`; + const refId = await createDoc(originalName); - await waitFor( - () => findDoc(refId) !== undefined, - `Document ${refId} should appear in user state`, - ); + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); - const initialDoc = findDoc(refId); - assert(initialDoc); - const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); - const handle = await getLiveHandle(refId); - assert.strictEqual(handle.doc().name, originalName); + const handle = await getLiveHandle(refId); + assert.strictEqual(handle.doc().name, originalName); - const editedName = `Revert Edited - ${v4()}`; - handle.change((doc) => { - doc.name = editedName; - }); - assert.strictEqual(handle.doc().name, editedName); + const editedName = `Revert Edited - ${v4()}`; + handle.change((doc) => { + doc.name = editedName; + }); + assert.strictEqual(handle.doc().name, editedName); - await waitFor(() => { - const doc = findDoc(refId); - return doc !== undefined && Object.keys(doc.snapshots).length >= 2; - }, "Should have two snapshots after autosave"); + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); - // Navigate back to the original snapshot. - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + // Navigate back to the original snapshot. + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); - // The live Automerge document should revert to the original content. - await waitFor( - () => handle.doc().name === originalName, - `Live document name should revert to "${originalName}" but is "${handle.doc().name}"`, - ); + // The live Automerge document should revert to the original content. + await waitFor( + () => handle.doc().name === originalName, + `Live document name should revert to "${originalName}" but is "${handle.doc().name}"`, + ); - assert.strictEqual( - handle.doc().name, - originalName, - "Live document should have original name after reverting", - ); - }, - ); + assert.strictEqual( + handle.doc().name, + originalName, + "Live document should have original name after reverting", + ); + }); // --------------------------------------------------------------- // Test 6: set_current_snapshot updates the database snapshot content // --------------------------------------------------------------- - test.sequential( - "should update head_snapshot content after navigating to an older snapshot", - async () => { - await signInWithEmailAndPassword(auth, email, password); + test.sequential("should update head_snapshot content after navigating to an older snapshot", async () => { + await signInWithEmailAndPassword(auth, email, password); - const originalName = `DB Revert Original - ${v4()}`; - const refId = await createDoc(originalName); + const originalName = `DB Revert Original - ${v4()}`; + const refId = await createDoc(originalName); - await waitFor( - () => findDoc(refId) !== undefined, - `Document ${refId} should appear in user state`, - ); + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); - const initialDoc = findDoc(refId); - assert(initialDoc); - const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); - const handle = await getLiveHandle(refId); + const handle = await getLiveHandle(refId); - const editedName = `DB Revert Edited - ${v4()}`; - handle.change((doc) => { - doc.name = editedName; - }); + const editedName = `DB Revert Edited - ${v4()}`; + handle.change((doc) => { + doc.name = editedName; + }); - await waitFor(() => { - const doc = findDoc(refId); - return doc !== undefined && Object.keys(doc.snapshots).length >= 2; - }, "Should have two snapshots after autosave"); + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after autosave"); - // Verify the head_snapshot shows the edited version. - const editedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< - string, - unknown - >; - assert.strictEqual(editedContent.name, editedName, "head_snapshot should show edited name"); + // Verify the head_snapshot shows the edited version. + const editedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< + string, + unknown + >; + assert.strictEqual(editedContent.name, editedName, "head_snapshot should show edited name"); - // Navigate back to original. - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + // Navigate back to original. + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); - // The head_snapshot should now point to the original snapshot's content. - await waitFor(() => { - const doc = findDoc(refId); - return doc !== undefined && doc.currentSnapshot === originalSnapshotId; - }, "currentSnapshot should point to original"); + // The head_snapshot should now point to the original snapshot's content. + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && doc.currentSnapshot === originalSnapshotId; + }, "currentSnapshot should point to original"); - const revertedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< - string, - unknown - >; - assert.strictEqual( - revertedContent.name, - originalName, - "head_snapshot should show original name after revert", - ); - }, - ); + const revertedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< + string, + unknown + >; + assert.strictEqual( + revertedContent.name, + originalName, + "head_snapshot should show original name after revert", + ); + }); // --------------------------------------------------------------- // Test 7: Multiple edits → multiple snapshots → navigate history @@ -520,67 +514,65 @@ describe("Document editing, snapshots, and undo/redo", async () => { // --------------------------------------------------------------- // Test 10: Content beyond just name is preserved during revert // --------------------------------------------------------------- - test.sequential( - "should preserve full document structure when reverting snapshots", - async () => { - await signInWithEmailAndPassword(auth, email, password); + test.sequential("should preserve full document structure when reverting snapshots", async () => { + await signInWithEmailAndPassword(auth, email, password); - const name = `Structure Test - ${v4()}`; - const refId = await createDoc(name); + const name = `Structure Test - ${v4()}`; + const refId = await createDoc(name); - await waitFor( - () => findDoc(refId) !== undefined, - `Document ${refId} should appear in user state`, - ); + await waitFor( + () => findDoc(refId) !== undefined, + `Document ${refId} should appear in user state`, + ); - const initialDoc = findDoc(refId); - assert(initialDoc); - const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); + const initialDoc = findDoc(refId); + assert(initialDoc); + const originalSnapshotId = Number(Object.keys(initialDoc.snapshots)[0]!); - const refDoc = unwrap(await rpc.get_doc.query(refId)); - assert(refDoc.tag === "Live"); - assert(isValidDocumentId(refDoc.docId)); - const handle: DocHandle = await repo.find(refDoc.docId); - await handle.whenReady(); + const refDoc = unwrap(await rpc.get_doc.query(refId)); + assert(refDoc.tag === "Live"); + assert(isValidDocumentId(refDoc.docId)); + const handle: DocHandle = await repo.find(refDoc.docId); + await handle.whenReady(); - const doc = handle.doc(); - assert.strictEqual(doc.type, "model", "Document type should be model"); - assert.strictEqual(doc.theory, "empty", "Document theory should be empty"); - assert.deepStrictEqual(doc.notebook.cellOrder, [], "Cell order should be empty"); + const doc = handle.doc(); + assert.strictEqual(doc.type, "model", "Document type should be model"); + assert.strictEqual(doc.theory, "empty", "Document theory should be empty"); + assert.deepStrictEqual(doc.notebook.cellOrder, [], "Cell order should be empty"); - handle.change((doc) => { - doc.name = `Structure Edited - ${v4()}`; - doc.theory = "causal-loop"; - doc.notebook.cellOrder = ["cell1"]; - doc.notebook.cellContents.cell1 = { - tag: "rich-text", - content: "Hello world", - } as any; - }); + handle.change((doc) => { + doc.name = `Structure Edited - ${v4()}`; + doc.theory = "causal-loop"; + doc.notebook.cellOrder = ["cell1"]; + doc.notebook.cellContents.cell1 = { + tag: "rich-text", + id: "cell1", + content: "Hello world", + }; + }); - await waitFor(() => { - const doc = findDoc(refId); - return doc !== undefined && Object.keys(doc.snapshots).length >= 2; - }, "Should have two snapshots after edit"); + await waitFor(() => { + const doc = findDoc(refId); + return doc !== undefined && Object.keys(doc.snapshots).length >= 2; + }, "Should have two snapshots after edit"); - // Revert to original - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + // Revert to original + unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); - await waitFor( - () => handle.doc().theory === "empty", - `Theory should revert to "empty" but is "${handle.doc().theory}"`, - ); + await waitFor( + () => handle.doc().theory === "empty", + `Theory should revert to "empty" but is "${handle.doc().theory}"`, + ); - const reverted = handle.doc(); - assert.strictEqual(reverted.name, name, "Name should revert"); - assert.strictEqual(reverted.theory, "empty", "Theory should revert to empty"); - assert.deepStrictEqual( - reverted.notebook.cellOrder, - [], - "Cell order should revert to empty", - ); - }, - ); + const reverted = handle.doc(); + assert.strictEqual(reverted.name, name, "Name should revert"); + assert.strictEqual(reverted.theory, "empty", "Theory should revert to empty"); + assert.deepStrictEqual( + reverted.notebook.cellOrder, + [], + "Cell order should revert to empty", + ); + }); // --------------------------------------------------------------- // Test 11: set_current_snapshot should NOT create a spurious snapshot @@ -678,7 +670,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { tag: "rich-text", id: cellId, content: "", - } as any; + }; Automerge.splice( doc, ["notebook", "cellContents", cellId, "content"], @@ -695,8 +687,8 @@ describe("Document editing, snapshots, and undo/redo", async () => { ); }); - const textPath = ["notebook", "cellContents", cellId, "content"] as const; - const marksBeforeSnapshot = Automerge.marks(handle.doc(), textPath as any); + const textPath = ["notebook", "cellContents", cellId, "content"]; + const marksBeforeSnapshot = Automerge.marks(handle.doc(), textPath); assert.strictEqual(marksBeforeSnapshot.length, 1, "Should have one bold mark"); assert.strictEqual(marksBeforeSnapshot[0]!.name, "bold"); assert.strictEqual(marksBeforeSnapshot[0]!.start, 6); @@ -730,7 +722,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { // The critical assertion: marks must survive the round-trip through // hydrate_to_json → populate_automerge_from_json. - const marksAfterRevert = Automerge.marks(handle.doc(), textPath as any); + const marksAfterRevert = Automerge.marks(handle.doc(), textPath); assert.strictEqual( marksAfterRevert.length, 1, @@ -775,7 +767,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { tag: "rich-text", id: cellA, content: "", - } as any; + }; Automerge.splice( doc, ["notebook", "cellContents", cellA, "content"], @@ -789,7 +781,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { tag: "rich-text", id: cellB, content: "", - } as any; + }; Automerge.splice( doc, ["notebook", "cellContents", cellB, "content"], @@ -799,10 +791,10 @@ describe("Document editing, snapshots, and undo/redo", async () => { ); }); - const contentOf = (cellId: string) => - (handle.doc().notebook.cellContents[cellId] as any)?.content as - | string - | undefined; + const contentOf = (cellId: string) => { + const cell = handle.doc().notebook.cellContents[cellId]; + return cell?.tag === "rich-text" ? cell.content : undefined; + }; assert.strictEqual(contentOf(cellA), "Alpha content"); assert.strictEqual(contentOf(cellB), "Beta content"); @@ -874,8 +866,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual( contentOf(cellB), "Beta content", - `After redo, cell B must keep its own content, ` + - `but got "${contentOf(cellB)}"`, + `After redo, cell B must keep its own content, but got "${contentOf(cellB)}"`, ); }, ); @@ -915,25 +906,21 @@ describe("Document editing, snapshots, and undo/redo", async () => { tag: "rich-text", id: cellId, content: "", - } as any; + }; Automerge.splitBlock(doc, contentPath, 0, { type: "paragraph", }); Automerge.splice(doc, contentPath, 1, 0, "hello 1"); }); - const spansBefore = Automerge.spans(handle.doc(), contentPath as any); + const spansBefore = Automerge.spans(handle.doc(), contentPath); assert.strictEqual(spansBefore.length, 2, "Should have block + text span"); assert.strictEqual( spansBefore[0]!.type, "block", "First span should be a block marker", ); - assert.strictEqual( - spansBefore[1]!.type, - "text", - "Second span should be text", - ); + assert.strictEqual(spansBefore[1]!.type, "text", "Second span should be text"); assert.strictEqual(spansBefore[1]!.value, "hello 1"); await waitFor(() => { @@ -957,14 +944,11 @@ describe("Document editing, snapshots, and undo/redo", async () => { // Undo: navigate back to the snapshot with the block marker. unwrap(await rpc.set_current_snapshot.mutate(refId, withBlockSnapshotId)); - await waitFor( - () => handle.doc().name === name, - `Should revert to original name`, - ); + await waitFor(() => handle.doc().name === name, `Should revert to original name`); // The critical check: spans must still be structural, not // literal U+FFFC characters in the text. - const spansAfter = Automerge.spans(handle.doc(), contentPath as any); + const spansAfter = Automerge.spans(handle.doc(), contentPath); assert.strictEqual( spansAfter.length, 2, diff --git a/packages/frontend/src/api/user_state.test.ts b/packages/frontend/src/api/user_state.test.ts index deda21146..e9ac4308f 100644 --- a/packages/frontend/src/api/user_state.test.ts +++ b/packages/frontend/src/api/user_state.test.ts @@ -298,7 +298,11 @@ describe("User state Automerge document", async () => { afterAutosave.currentSnapshot !== originalSnapshotId, "currentSnapshot should have changed after autosave", ); - assert.strictEqual(Object.keys(afterAutosave.snapshots).length, 2, "Should have two snapshots"); + assert.strictEqual( + Object.keys(afterAutosave.snapshots).length, + 2, + "Should have two snapshots", + ); unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); diff --git a/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx b/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx index a289bd52d..ad06f014b 100644 --- a/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx +++ b/packages/frontend/src/components/rich_text_editor/rich_text_editor.tsx @@ -492,9 +492,13 @@ function TooltipButton(props: { /** True when `candidate` is a prefix of (or equal to) `target`. */ function isPathPrefixOf(candidate: Prop[], target: Prop[]): boolean { - if (candidate.length > target.length) return false; + if (candidate.length > target.length) { + return false; + } for (let i = 0; i < candidate.length; i++) { - if (candidate[i] !== target[i]) return false; + if (candidate[i] !== target[i]) { + return false; + } } return true; } @@ -506,8 +510,6 @@ function isPathPrefixOf(candidate: Prop[], target: Prop[]): boolean { */ function hasStructuralReplacement(patches: Patch[], textPath: Prop[]): boolean { return patches.some( - (p) => - (p.action === "put" || p.action === "del") && - isPathPrefixOf(p.path, textPath), + (p) => (p.action === "put" || p.action === "del") && isPathPrefixOf(p.path, textPath), ); } diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 0309984c9..a00fe8b88 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -1,11 +1,11 @@ import Resizable, { type ContextValue } from "@corvu/resizable"; +import { makeEventListener } from "@solid-primitives/event-listener"; import { Title } from "@solidjs/meta"; import { useNavigate, useParams } from "@solidjs/router"; import ChevronsRight from "lucide-solid/icons/chevrons-right"; import History from "lucide-solid/icons/history"; import Maximize2 from "lucide-solid/icons/maximize-2"; import RotateCcw from "lucide-solid/icons/rotate-ccw"; -import { makeEventListener } from "@solid-primitives/event-listener"; import { createEffect, createMemo, @@ -231,7 +231,10 @@ function SplitPaneToolbar(props: { class="primary-permissions-toolbar toolbar" style={{ left: `${(primaryPanelSize() ?? 0) * 100}%` }} > - + @@ -400,7 +403,9 @@ export function DocumentPane(props: { makeEventListener(window, "keydown", (evt) => { const mod = evt.metaKey || evt.ctrlKey; - if (!mod || evt.altKey) return; + if (!mod || evt.altKey) { + return; + } if (evt.key === "z" || evt.key === "Z") { if (evt.shiftKey) { @@ -465,9 +470,7 @@ export function DocumentPane(props: { {(liveModel) => } - {(liveDiagram) => ( - - )} + {(liveDiagram) => } {(liveAnalysis) => ( diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx index 3aa7b2e75..feada930c 100644 --- a/packages/frontend/src/page/history_sidebar.tsx +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -1,23 +1,23 @@ import { HistoryNavigator } from "catcolab-ui-components"; - import { type SnapshotHistory, useSnapshotHistory } from "./use_snapshot_history"; const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent); -const mod = isMac ? "\u2318" : "Ctrl+"; +const mod = isMac ? "\u2318" : "Ctrl"; export function HistorySidebar(props: { refId: string; history?: SnapshotHistory }) { - const history = props.history ?? useSnapshotHistory(() => props.refId); + const ownHistory = useSnapshotHistory(() => props.refId); + const history = () => props.history ?? ownHistory; return ( ); } diff --git a/packages/frontend/src/page/use_snapshot_history.ts b/packages/frontend/src/page/use_snapshot_history.ts index b594a916d..a739be800 100644 --- a/packages/frontend/src/page/use_snapshot_history.ts +++ b/packages/frontend/src/page/use_snapshot_history.ts @@ -1,8 +1,7 @@ +import type { SnapshotInfo } from "catcolab-api/src/user_state"; import { type Accessor, createMemo } from "solid-js"; import type { HistoryItem } from "catcolab-ui-components"; -import type { SnapshotInfo } from "catcolab-api/src/user_state"; - import { useApi } from "../api"; import { useUserState } from "../user/user_state_context"; @@ -103,12 +102,16 @@ export function useSnapshotHistory(refId: Accessor): SnapshotHistory { const onUndo = () => { const idx = currentIndex(); const prev = chain()[idx - 1]; - if (idx > 0 && prev != null) navigate(prev); + if (idx > 0 && prev != null) { + navigate(prev); + } }; const onRedo = () => { const child = newestChild(head(), snapshots()); - if (child != null) navigate(child); + if (child != null) { + navigate(child); + } }; return { items, canUndo, canRedo, onUndo, onRedo, navigate }; diff --git a/packages/ui-components/src/history_navigator.stories.tsx b/packages/ui-components/src/history_navigator.stories.tsx index b76ad57f4..95a0ee9a4 100644 --- a/packages/ui-components/src/history_navigator.stories.tsx +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -92,7 +92,7 @@ function buildFullChain(head: string, history: Record): st let current: string | null = head; while (current != null && history[current] != null) { backwards.push(current); - current = history[current]!.parent ?? null; + current = history[current].parent ?? null; } backwards.reverse(); @@ -112,7 +112,7 @@ function chainToItems( ): HistoryItem[] { const items: HistoryItem[] = []; for (let i = chain.length - 1; i >= 0; i--) { - const id = chain[i]!; + const id = chain[i]; const e = history[id]; if (e) { items.push({ id, createdAt: e.createdAt, active: id === head }); @@ -136,12 +136,16 @@ function InteractiveStory(props: { initialHead: string }) { const onUndo = () => { const idx = currentIndex(); const prev = chain()[idx - 1]; - if (idx > 0 && prev != null) setHead(prev); + if (idx > 0 && prev != null) { + setHead(prev); + } }; const onRedo = () => { const child = newestChild(head(), history()); - if (child != null) setHead(child); + if (child != null) { + setHead(child); + } }; const simulateChange = () => { @@ -212,11 +216,7 @@ function staticItems(offsets: { label: string; ago: number }[]): HistoryItem[] { })); } -function StaticStory(props: { - items: HistoryItem[]; - undoTooltip?: string; - redoTooltip?: string; -}) { +function StaticStory(props: { items: HistoryItem[]; undoTooltip?: string; redoTooltip?: string }) { return (
(); const suffixByIndex = new Map(); for (let i = raw.length - 1; i >= 0; i--) { - const item = raw[i]!; + const item = raw[i]; + if (!item) { + continue; + } const total = countPerMinute.get(item.minuteKey) ?? 1; if (total > 1) { const idx = (indexPerMinute.get(item.minuteKey) ?? 0) + 1; @@ -111,7 +123,9 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { const activeIndex = createMemo(() => { const items = displayItems(); for (let i = 0; i < items.length; i++) { - if (items[i]?.active) return i; + if (items[i]?.active) { + return i; + } } return -1; }); @@ -138,7 +152,9 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { createEffect(() => { const idx = activeIndex(); const el = scrollContainerEl; - if (!el || idx < 0) return; + if (!el || idx < 0) { + return; + } const rowTop = idx * ROW_HEIGHT; const rowBottom = rowTop + ROW_HEIGHT; @@ -155,10 +171,18 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { return (
- + - +
From 17093ba9535cc360ce12540570bfff5fa2aee34f Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 1 Apr 2026 17:54:11 +0100 Subject: [PATCH 24/38] TEST: Use property tests on automerge_json and automerge_util --- Cargo.lock | 2 + packages/notebook-types/Cargo.toml | 4 + packages/notebook-types/src/automerge_json.rs | 26 +++++ packages/notebook-types/src/automerge_util.rs | 86 ++++++++++++--- packages/notebook-types/src/common_test.rs | 29 +++++ packages/notebook-types/src/lib.rs | 3 + packages/notebook-types/src/v0/api.rs | 45 +++++++- packages/notebook-types/src/v0/cell.rs | 25 +++++ packages/notebook-types/src/v0/model.rs | 66 +++++++++++ .../notebook-types/src/v0/model_judgment.rs | 103 ++++++++++++++++++ packages/notebook-types/src/v0/path.rs | 19 ++++ packages/notebook-types/src/v0/theory.rs | 96 ++++++++++++++++ packages/notebook-types/src/v1/notebook.rs | 54 +++++++++ 13 files changed, 539 insertions(+), 19 deletions(-) create mode 100644 packages/notebook-types/src/common_test.rs diff --git a/Cargo.lock b/Cargo.lock index 409b5c8d3..cb5b3c501 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2678,9 +2678,11 @@ version = "0.1.0" dependencies = [ "automerge", "autosurgeon", + "proptest", "serde", "serde-wasm-bindgen", "serde_json", + "test-strategy", "ts-rs", "tsify", "ustr", diff --git a/packages/notebook-types/Cargo.toml b/packages/notebook-types/Cargo.toml index 921666cd5..eb101e87a 100644 --- a/packages/notebook-types/Cargo.toml +++ b/packages/notebook-types/Cargo.toml @@ -12,6 +12,7 @@ path = "src/bin/migrate_examples.rs" [features] backend = ["dep:automerge", "dep:autosurgeon", "dep:ts-rs"] +property-tests = ["backend", "dep:proptest", "dep:test-strategy"] [dependencies] automerge = { version = "0.8.0", optional = true } @@ -25,5 +26,8 @@ ustr = { version = "1.1.0", features = ["serde"] } uuid = { version = "1.18", features = ["serde"] } wasm-bindgen = "0.2.106" +proptest = { version = "1.9.0", optional = true } +test-strategy = { version = "0.4", optional = true } + [lints.clippy] doc_paragraphs_missing_punctuation = "warn" diff --git a/packages/notebook-types/src/automerge_json.rs b/packages/notebook-types/src/automerge_json.rs index 65e08b9bd..cfe562820 100644 --- a/packages/notebook-types/src/automerge_json.rs +++ b/packages/notebook-types/src/automerge_json.rs @@ -160,3 +160,29 @@ fn scalar_to_json(s: &automerge::ScalarValue) -> Value { ])), } } + +#[cfg(all(test, feature = "property-tests"))] +mod tests { + use super::*; + use crate::common_test::roundtrip_json; + use crate::v1::notebook::ModelNotebook; + use automerge::Automerge; + use test_strategy::proptest; + + /// A `ModelNotebook` survives a JSON → Automerge → JSON roundtrip. + #[proptest(cases = 64)] + fn model_notebook_roundtrips_through_automerge(notebook: ModelNotebook) { + let json = serde_json::to_value(¬ebook.0).expect("serialize to JSON"); + let result = roundtrip_json(&json); + proptest::prop_assert_eq!(json, result); + } + + /// Non-object root values are rejected by `populate_automerge_from_json`. + #[proptest(cases = 64)] + fn non_object_root_is_rejected(value: bool) { + let json = Value::Bool(value); + let mut doc = Automerge::new(); + let result = doc.transact(|tx| populate_automerge_from_json(tx, automerge::ROOT, &json)); + proptest::prop_assert!(result.is_err()); + } +} diff --git a/packages/notebook-types/src/automerge_util.rs b/packages/notebook-types/src/automerge_util.rs index 366bcced4..4e41bfd29 100644 --- a/packages/notebook-types/src/automerge_util.rs +++ b/packages/notebook-types/src/automerge_util.rs @@ -158,27 +158,10 @@ fn insert_value_into_list_from_doc<'a>( #[cfg(test)] mod tests { use super::*; - use crate::automerge_json::{hydrate_to_json, populate_automerge_from_json}; + use crate::common_test::{doc_from_json, doc_to_json}; use automerge::{Automerge, ObjType, ReadDoc}; use serde_json::json; - /// Helper: create doc populated from JSON. - fn doc_from_json(value: &serde_json::Value) -> Automerge { - let mut doc = Automerge::new(); - doc.transact(|tx| { - populate_automerge_from_json(tx, automerge::ROOT, value).unwrap(); - Ok::<_, automerge::AutomergeError>(()) - }) - .unwrap(); - doc - } - - /// Helper: read the current doc state back as JSON. - fn doc_to_json(doc: &Automerge) -> serde_json::Value { - let value = doc.hydrate(None); - hydrate_to_json(&value) - } - #[test] fn copy_restores_scalar_fields() { let mut doc = doc_from_json(&json!({ @@ -375,3 +358,70 @@ mod tests { assert!(keys.is_empty()); } } + +#[cfg(all(test, feature = "property-tests"))] +mod property_tests { + use super::*; + use crate::common_test::{doc_from_json, doc_to_json}; + use crate::v1::notebook::ModelNotebook; + use automerge::ReadDoc; + use test_strategy::proptest; + + /// After mutating a doc and then restoring via `copy_doc_at_heads`, the + /// JSON representation matches the original. + #[proptest(cases = 64)] + fn copy_doc_at_heads_restores_model_notebook(notebook: ModelNotebook) { + let json = serde_json::to_value(¬ebook.0).expect("serialize to JSON"); + let mut doc = doc_from_json(&json); + let original_heads = doc.get_heads(); + + // Mutate: clear all keys from root. + doc.transact(|tx| { + let keys: Vec = tx.keys(automerge::ROOT).collect(); + for key in keys { + tx.delete(automerge::ROOT, key.as_str())?; + } + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Restore to original heads. + doc.transact(|tx| { + copy_doc_at_heads(tx, &original_heads)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let result = doc_to_json(&doc); + proptest::prop_assert_eq!(json, result); + } + + /// Restoring to empty heads after populating yields an empty document. + #[proptest(cases = 64)] + fn copy_doc_at_heads_restores_to_empty(notebook: ModelNotebook) { + let json = serde_json::to_value(¬ebook.0).expect("serialize to JSON"); + let mut doc = automerge::Automerge::new(); + let empty_heads = doc.get_heads(); + + // Populate the doc. + doc.transact(|tx| { + crate::automerge_json::populate_automerge_from_json(tx, automerge::ROOT, &json) + .unwrap(); + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + // Restore to empty. + doc.transact(|tx| { + copy_doc_at_heads(tx, &empty_heads)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + + let keys: Vec = doc.keys(automerge::ROOT).collect(); + proptest::prop_assert!( + keys.is_empty(), + "doc should be empty after restoring to empty heads" + ); + } +} diff --git a/packages/notebook-types/src/common_test.rs b/packages/notebook-types/src/common_test.rs new file mode 100644 index 000000000..8cc6045d0 --- /dev/null +++ b/packages/notebook-types/src/common_test.rs @@ -0,0 +1,29 @@ +//! Shared test helpers for Automerge roundtrip tests. + +use automerge::Automerge; +use serde_json::Value; + +use crate::automerge_json::{hydrate_to_json, populate_automerge_from_json}; + +/// Create an Automerge doc populated from a JSON object. +pub fn doc_from_json(value: &Value) -> Automerge { + let mut doc = Automerge::new(); + doc.transact(|tx| { + populate_automerge_from_json(tx, automerge::ROOT, value).unwrap(); + Ok::<_, automerge::AutomergeError>(()) + }) + .unwrap(); + doc +} + +/// Read the current doc state back as JSON. +pub fn doc_to_json(doc: &Automerge) -> Value { + let value = doc.hydrate(None); + hydrate_to_json(&value) +} + +/// Roundtrip a JSON object through Automerge and back. +pub fn roundtrip_json(json: &Value) -> Value { + let doc = doc_from_json(json); + doc_to_json(&doc) +} diff --git a/packages/notebook-types/src/lib.rs b/packages/notebook-types/src/lib.rs index 88300404b..78b4b656e 100644 --- a/packages/notebook-types/src/lib.rs +++ b/packages/notebook-types/src/lib.rs @@ -15,6 +15,9 @@ pub mod automerge_util; #[cfg(test)] mod test_utils; +#[cfg(all(test, feature = "backend"))] +pub(crate) mod common_test; + pub mod current { // this should always track the latest version, and is the only version // that is exported from notebook-types diff --git a/packages/notebook-types/src/v0/api.rs b/packages/notebook-types/src/v0/api.rs index 769045106..5ab7838eb 100644 --- a/packages/notebook-types/src/v0/api.rs +++ b/packages/notebook-types/src/v0/api.rs @@ -48,7 +48,7 @@ pub struct Link { } /// Type of link between documents. -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Tsify)] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Tsify)] #[tsify(into_wasm_abi, from_wasm_abi)] pub enum LinkType { #[serde(rename = "analysis-of")] @@ -60,3 +60,46 @@ pub enum LinkType { #[serde(rename = "instantiation")] Instantiation, } + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use proptest::prelude::*; + + impl Arbitrary for LinkType { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + proptest::sample::select(&[ + LinkType::AnalysisOf, + LinkType::DiagramIn, + LinkType::Instantiation, + ]) + .boxed() + } + } + + impl Arbitrary for StableRef { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + (any::(), proptest::option::of(any::()), any::()) + .prop_map(|(id, version, server)| StableRef { id, version, server }) + .boxed() + } + } + + impl Arbitrary for Link { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + (any::(), any::()) + .prop_map(|(stable_ref, r#type)| Link { stable_ref, r#type }) + .boxed() + } + } +} diff --git a/packages/notebook-types/src/v0/cell.rs b/packages/notebook-types/src/v0/cell.rs index 68b1f7d20..f32956b5f 100644 --- a/packages/notebook-types/src/v0/cell.rs +++ b/packages/notebook-types/src/v0/cell.rs @@ -16,3 +16,28 @@ pub enum NotebookCell { #[declare] pub type Cell = NotebookCell; + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use proptest::prelude::*; + use uuid::Uuid; + + fn arb_uuid() -> BoxedStrategy { + any::().prop_map(Uuid::from_u128).boxed() + } + + /// Strategy for a `NotebookCell` given a strategy for `T`. + pub fn arb_notebook_cell( + arb_t: impl Strategy + Clone + 'static, + ) -> BoxedStrategy> { + prop_oneof![ + (arb_uuid(), any::()) + .prop_map(|(id, content)| NotebookCell::RichText { id, content }), + (arb_uuid(), arb_t).prop_map(|(id, content)| NotebookCell::Formal { id, content }), + arb_uuid().prop_map(|id| NotebookCell::Stem { id }), + ] + .boxed() + } +} diff --git a/packages/notebook-types/src/v0/model.rs b/packages/notebook-types/src/v0/model.rs index 21a041cd5..6ad4ad26a 100644 --- a/packages/notebook-types/src/v0/model.rs +++ b/packages/notebook-types/src/v0/model.rs @@ -43,3 +43,69 @@ pub enum Mor { post: Box, }, } + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use crate::v0::path::arbitrary::arb_path; + use proptest::prelude::*; + + /// Strategy for an `Ob` bounded by recursion depth. + pub fn arb_ob(depth: u32) -> BoxedStrategy { + let leaf = any::().prop_map(Ob::Basic); + if depth == 0 { + return leaf.boxed(); + } + prop_oneof![ + 3 => leaf, + 1 => (any::(), arb_ob(depth - 1)) + .prop_map(|(op, ob)| Ob::App { op, ob: Box::new(ob) }), + 1 => (any::(), prop::collection::vec( + proptest::option::of(arb_ob(depth - 1)), 0..3)) + .prop_map(|(modality, objects)| Ob::List { modality, objects }), + 1 => arb_mor(depth - 1).prop_map(Ob::Tabulated), + ] + .boxed() + } + + /// Strategy for a `Mor` bounded by recursion depth. + pub fn arb_mor(depth: u32) -> BoxedStrategy { + let leaf = any::().prop_map(Mor::Basic); + if depth == 0 { + return leaf.boxed(); + } + prop_oneof![ + 3 => leaf, + 1 => arb_path(arb_ob(depth - 1), arb_mor(depth - 1)) + .prop_map(|p| Mor::Composite(Box::new(p))), + 1 => (arb_mor(depth - 1), arb_mor(depth - 1), + arb_mor(depth - 1), arb_mor(depth - 1)) + .prop_map(|(dom, cod, pre, post)| Mor::TabulatorSquare { + dom: Box::new(dom), + cod: Box::new(cod), + pre: Box::new(pre), + post: Box::new(post), + }), + ] + .boxed() + } + + impl Arbitrary for Ob { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_ob(2).boxed() + } + } + + impl Arbitrary for Mor { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_mor(2).boxed() + } + } +} diff --git a/packages/notebook-types/src/v0/model_judgment.rs b/packages/notebook-types/src/v0/model_judgment.rs index 74b57b057..e9d7dde16 100644 --- a/packages/notebook-types/src/v0/model_judgment.rs +++ b/packages/notebook-types/src/v0/model_judgment.rs @@ -108,3 +108,106 @@ pub enum ModelJudgment { #[serde(rename = "instantiation")] Instantiation(InstantiatedModel), } + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use proptest::prelude::*; + use uuid::Uuid; + + fn arb_uuid() -> BoxedStrategy { + any::().prop_map(Uuid::from_u128).boxed() + } + + impl Arbitrary for ObDecl { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + (any::(), arb_uuid(), any::()) + .prop_map(|(name, id, ob_type)| ObDecl { name, id, ob_type }) + .boxed() + } + } + + impl Arbitrary for MorDecl { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + ( + any::(), + arb_uuid(), + any::(), + proptest::option::of(any::()), + proptest::option::of(any::()), + ) + .prop_map(|(name, id, mor_type, dom, cod)| MorDecl { name, id, mor_type, dom, cod }) + .boxed() + } + } + + impl Arbitrary for SpecializeModel { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + (proptest::option::of(any::()), proptest::option::of(any::())) + .prop_map(|(id, ob)| SpecializeModel { id, ob }) + .boxed() + } + } + + impl Arbitrary for InstantiatedModel { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + ( + any::(), + arb_uuid(), + proptest::option::of(any::()), + prop::collection::vec(any::(), 0..3), + ) + .prop_map(|(name, id, model, specializations)| InstantiatedModel { + name, + id, + model, + specializations, + }) + .boxed() + } + } + + impl Arbitrary for EqnDecl { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + ( + any::(), + arb_uuid(), + proptest::option::of(any::()), + proptest::option::of(any::()), + ) + .prop_map(|(name, id, lhs, rhs)| EqnDecl { name, id, lhs, rhs }) + .boxed() + } + } + + impl Arbitrary for ModelJudgment { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + prop_oneof![ + any::().prop_map(ModelJudgment::Object), + any::().prop_map(ModelJudgment::Morphism), + any::().prop_map(ModelJudgment::Equation), + any::().prop_map(ModelJudgment::Instantiation), + ] + .boxed() + } + } +} diff --git a/packages/notebook-types/src/v0/path.rs b/packages/notebook-types/src/v0/path.rs index 63554cdad..19791f716 100644 --- a/packages/notebook-types/src/v0/path.rs +++ b/packages/notebook-types/src/v0/path.rs @@ -8,3 +8,22 @@ pub enum Path { Id(V), Seq(Vec), } + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use proptest::prelude::*; + + /// Strategy for a `Path` given strategies for vertices and edges. + pub fn arb_path( + arb_v: impl Strategy + 'static, + arb_e: impl Strategy + 'static, + ) -> BoxedStrategy> { + prop_oneof![ + arb_v.prop_map(Path::Id), + prop::collection::vec(arb_e, 0..3).prop_map(Path::Seq), + ] + .boxed() + } +} diff --git a/packages/notebook-types/src/v0/theory.rs b/packages/notebook-types/src/v0/theory.rs index 1c00018aa..245a2848f 100644 --- a/packages/notebook-types/src/v0/theory.rs +++ b/packages/notebook-types/src/v0/theory.rs @@ -66,3 +66,99 @@ pub enum Modality { CartesianList, AdditiveList, } + +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use proptest::prelude::*; + use ustr::Ustr; + + /// Strategy for generating an arbitrary `Ustr`. + pub fn arb_ustr() -> BoxedStrategy { + "[a-zA-Z_][a-zA-Z0-9_]{0,8}".prop_map(|s| Ustr::from(&s)).boxed() + } + + impl Arbitrary for Modality { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + proptest::sample::select(&[ + Modality::Discrete, + Modality::Codiscrete, + Modality::List, + Modality::SymmetricList, + Modality::CocartesianList, + Modality::CartesianList, + Modality::AdditiveList, + ]) + .boxed() + } + } + + impl Arbitrary for ObOp { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_ustr().prop_map(ObOp::Basic).boxed() + } + } + + /// Strategy for an `ObType` bounded by recursion depth. + pub fn arb_ob_type(depth: u32) -> BoxedStrategy { + let leaf = arb_ustr().prop_map(ObType::Basic); + if depth == 0 { + return leaf.boxed(); + } + prop_oneof![ + 3 => leaf, + 1 => arb_mor_type(depth - 1).prop_map(|m| ObType::Tabulator(Box::new(m))), + 1 => (any::(), arb_ob_type(depth - 1)) + .prop_map(|(modality, ob_type)| ObType::ModeApp { + modality, + ob_type: Box::new(ob_type), + }), + ] + .boxed() + } + + /// Strategy for a `MorType` bounded by recursion depth. + pub fn arb_mor_type(depth: u32) -> BoxedStrategy { + let leaf = arb_ustr().prop_map(MorType::Basic); + if depth == 0 { + return leaf.boxed(); + } + prop_oneof![ + 3 => leaf, + 1 => arb_ob_type(depth - 1).prop_map(|o| MorType::Hom(Box::new(o))), + 1 => prop::collection::vec(arb_mor_type(depth - 1), 0..3) + .prop_map(MorType::Composite), + 1 => (any::(), arb_mor_type(depth - 1)) + .prop_map(|(modality, mor_type)| MorType::ModeApp { + modality, + mor_type: Box::new(mor_type), + }), + ] + .boxed() + } + + impl Arbitrary for ObType { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_ob_type(2).boxed() + } + } + + impl Arbitrary for MorType { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_mor_type(2).boxed() + } + } +} diff --git a/packages/notebook-types/src/v1/notebook.rs b/packages/notebook-types/src/v1/notebook.rs index a71c17459..d019abf36 100644 --- a/packages/notebook-types/src/v1/notebook.rs +++ b/packages/notebook-types/src/v1/notebook.rs @@ -24,6 +24,60 @@ pub struct ModelNotebook(pub Notebook); #[tsify(into_wasm_abi, from_wasm_abi)] pub struct DiagramNotebook(pub Notebook); +/// Arbitrary instances for property-based testing. +#[cfg(feature = "property-tests")] +pub(crate) mod arbitrary { + use super::*; + use crate::v0::cell::arbitrary::arb_notebook_cell; + use proptest::prelude::*; + + fn arb_uuid() -> BoxedStrategy { + any::().prop_map(Uuid::from_u128).boxed() + } + + /// Strategy for a `Notebook` given a strategy for `T`. + /// + /// Generates a consistent notebook where `cell_order` contains exactly + /// the keys in `cell_contents`. + pub fn arb_notebook( + arb_t: impl Strategy + Clone + 'static, + ) -> BoxedStrategy> { + prop::collection::vec((arb_uuid(), arb_notebook_cell(arb_t)), 0..6) + .prop_map(|entries| { + let mut cell_contents = HashMap::new(); + let mut cell_order = Vec::new(); + for (id, cell) in entries { + // Replace the cell's internal id with the map key for + // consistency, matching how real notebooks work. + let cell = match cell { + NotebookCell::RichText { content, .. } => { + NotebookCell::RichText { id, content } + } + NotebookCell::Formal { content, .. } => { + NotebookCell::Formal { id, content } + } + NotebookCell::Stem { .. } => NotebookCell::Stem { id }, + }; + cell_contents.insert(id, cell); + cell_order.push(id); + } + Notebook { cell_contents, cell_order } + }) + .boxed() + } + + impl Arbitrary for ModelNotebook { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + arb_notebook(any::()) + .prop_map(ModelNotebook) + .boxed() + } + } +} + impl Notebook { pub fn cells(&self) -> impl Iterator> { self.cell_order.iter().filter_map(|id| self.cell_contents.get(id)) From 41288bd76289bff4dd41a73994ca2f07676bc52a Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 1 Apr 2026 20:06:13 +0100 Subject: [PATCH 25/38] FIX: Possible race conditions around writing `current_snapshot` --- packages/backend/src/app.rs | 26 +++++++++++-- packages/backend/src/autosave.rs | 22 ++++++++--- packages/backend/src/document.rs | 41 +++++++++------------ packages/backend/src/main.rs | 2 +- packages/backend/tests/common/test_utils.rs | 2 +- packages/backend/tests/user_state_tests.rs | 2 +- 6 files changed, 59 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/app.rs b/packages/backend/src/app.rs index 249d52675..d0d1edcf7 100644 --- a/packages/backend/src/app.rs +++ b/packages/backend/src/app.rs @@ -4,7 +4,7 @@ use sqlx::PgPool; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use thiserror::Error; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; /// Top-level application state. @@ -21,8 +21,12 @@ pub struct AppState { /// Tracks which ref_ids have active autosave listeners to prevent duplicates. pub active_listeners: Arc>>, - /// Ref IDs whose next autosave should be skipped (e.g., during snapshot navigation). - pub suppress_autosave: Arc>>, + /// Per-ref mutex guarding modifications to `current_snapshot`. + /// + /// Both autosave (`create_snapshot`) and `set_current_snapshot` need to + /// coordinate: `set_current_snapshot` acquires the lock and waits, + /// while autosave uses `try_lock` and silently skips when the lock is held. + pub modifying_current_snapshot: Arc>>>>, /// Tracks user IDs whose state docs were refreshed from DB in this process, /// mapped to their Automerge document IDs. @@ -35,6 +39,22 @@ pub struct AppState { pub julia_url: Option, } +impl AppState { + /// Get or create the per-ref mutex for `current_snapshot` modifications. + pub async fn snapshot_lock(&self, ref_id: Uuid) -> Arc> { + // Fast path: read lock. + { + let locks = self.modifying_current_snapshot.read().await; + if let Some(lock) = locks.get(&ref_id) { + return lock.clone(); + } + } + // Slow path: write lock to insert. + let mut locks = self.modifying_current_snapshot.write().await; + locks.entry(ref_id).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + } +} + /// Context available to RPC procedures. #[derive(Clone)] pub struct AppCtx { diff --git a/packages/backend/src/autosave.rs b/packages/backend/src/autosave.rs index 0a55903be..ec5460031 100644 --- a/packages/backend/src/autosave.rs +++ b/packages/backend/src/autosave.rs @@ -30,19 +30,29 @@ pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: let mut snapshot_handle: Option> = None; while (changes.next().await).is_some() { - if state.suppress_autosave.read().await.contains(&ref_id) { - if let Some(handle) = snapshot_handle.take() { - handle.abort(); - } - continue; - } if let Some(handle) = snapshot_handle.take() { handle.abort(); } + let lock = state.snapshot_lock(ref_id).await; + let _guard = match lock.try_lock() { + Ok(guard) => guard, + Err(_) => { + // we can't acquire the lock ignore this change + continue; + } + }; snapshot_handle = Some(tokio::spawn({ let state = state.clone(); async move { tokio::time::sleep(Duration::from_millis(SNAPSHOT_DEBOUNCE_MS)).await; + let lock = state.snapshot_lock(ref_id).await; + let _guard = match lock.try_lock() { + Ok(guard) => guard, + Err(_) => { + // we can't acquire the lock ignore this change + return; + } + }; if let Err(e) = create_snapshot(state, ref_id).await { tracing::error!("Snapshot failed for ref {}: {:?}", ref_id, e); } diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index a2984802f..5468a8fea 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -220,37 +220,30 @@ pub async fn set_current_snapshot( .await? .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - state.suppress_autosave.write().await.insert(ref_id); - - let result: Result<(), AppError> = async { - doc_handle.with_document(|doc| -> Result<(), AppError> { - doc.transact::<_, _, automerge::AutomergeError>(|tx| { - copy_doc_at_heads(tx, &target_heads)?; - Ok(()) - }) - .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; - + // Acquire the per-ref lock. This waits if autosave currently holds it, and while held, + // autosave will not be able to acquire it and will skip changes + let lock = state.snapshot_lock(ref_id).await; + let _guard = lock.lock().await; + + doc_handle.with_document(|doc| -> Result<(), AppError> { + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + copy_doc_at_heads(tx, &target_heads)?; Ok(()) - })?; + }) + .map_err(|e| AppError::Invalid(format!("Failed to update document: {e:?}")))?; - sqlx::query!( - "UPDATE refs SET current_snapshot = $2 WHERE id = $1", - ref_id, - snapshot_id, - ) + Ok(()) + })?; + + sqlx::query!("UPDATE refs SET current_snapshot = $2 WHERE id = $1", ref_id, snapshot_id,) .execute(&state.db) .await?; - if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { - tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); - } - - Ok(()) + if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { + tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); } - .await; - state.suppress_autosave.write().await.remove(&ref_id); - result + Ok(()) } /// Soft-deletes a document reference by setting `deleted_at`. diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index 86b38e71d..8c36df39e 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -137,7 +137,7 @@ async fn main() { db: db.clone(), repo, active_listeners: Arc::new(RwLock::new(HashSet::new())), - suppress_autosave: Arc::new(RwLock::new(HashSet::new())), + modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client, julia_url, diff --git a/packages/backend/tests/common/test_utils.rs b/packages/backend/tests/common/test_utils.rs index e29d1563b..a8d4c7ead 100644 --- a/packages/backend/tests/common/test_utils.rs +++ b/packages/backend/tests/common/test_utils.rs @@ -49,7 +49,7 @@ pub async fn create_test_app_state(pool: PgPool) -> AppState { initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, - suppress_autosave: Arc::new(RwLock::new(HashSet::new())), + modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), } } diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index d313d7115..cf59c9817 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -1111,7 +1111,7 @@ mod integration_tests { initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, - suppress_autosave: Arc::new(RwLock::new(HashSet::new())), + modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), }; let expected_state = From 3960c16e2d3d0f617df834069ddac58ce95c2d10 Mon Sep 17 00:00:00 2001 From: Jason Moggridge Date: Wed, 1 Apr 2026 19:11:36 -0400 Subject: [PATCH 26/38] REFACTOR: Replace autosave listener with per-ref actor --- packages/backend/src/app.rs | 52 ++++----- packages/backend/src/autosave.rs | 67 ----------- packages/backend/src/document.rs | 31 ++--- packages/backend/src/lib.rs | 4 +- packages/backend/src/main.rs | 5 +- packages/backend/src/ref_actor.rs | 122 ++++++++++++++++++++ packages/backend/src/rpc.rs | 14 +-- packages/backend/tests/common/test_utils.rs | 5 +- packages/backend/tests/user_state_tests.rs | 5 +- 9 files changed, 173 insertions(+), 132 deletions(-) delete mode 100644 packages/backend/src/autosave.rs create mode 100644 packages/backend/src/ref_actor.rs diff --git a/packages/backend/src/app.rs b/packages/backend/src/app.rs index d0d1edcf7..033854993 100644 --- a/packages/backend/src/app.rs +++ b/packages/backend/src/app.rs @@ -1,12 +1,33 @@ use firebase_auth::FirebaseUser; use samod::DocumentId; use sqlx::PgPool; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{RwLock, mpsc, oneshot}; use uuid::Uuid; +/// Reply channel type used by all ref actor messages. +pub type RefReply = oneshot::Sender>; + +/// Type alias for the ref actors channel map. +pub type RefActorsMap = Arc>>>; + +/// Message sent to the ref actor for a document ref. +pub enum RefMsg { + /// Request an immediate snapshot (manual save / RPC call). + CreateSnapshot, + /// Set the current snapshot for the document ref. + SetCurrentSnapshot { + /// The target snapshot to set as current. + snapshot_id: i32, + }, + /// Soft-delete the document ref. + Delete, + /// Restore a soft-deleted document ref. + Restore, +} + /// Top-level application state. /// /// Cheaply cloneable and intended to be moved around the program. @@ -18,15 +39,8 @@ pub struct AppState { /// Automerge-repo provider. pub repo: samod::Repo, - /// Tracks which ref_ids have active autosave listeners to prevent duplicates. - pub active_listeners: Arc>>, - - /// Per-ref mutex guarding modifications to `current_snapshot`. - /// - /// Both autosave (`create_snapshot`) and `set_current_snapshot` need to - /// coordinate: `set_current_snapshot` acquires the lock and waits, - /// while autosave uses `try_lock` and silently skips when the lock is held. - pub modifying_current_snapshot: Arc>>>>, + /// Channel senders for per-ref actors that coordinate document mutations. + pub ref_actors: RefActorsMap, /// Tracks user IDs whose state docs were refreshed from DB in this process, /// mapped to their Automerge document IDs. @@ -39,22 +53,6 @@ pub struct AppState { pub julia_url: Option, } -impl AppState { - /// Get or create the per-ref mutex for `current_snapshot` modifications. - pub async fn snapshot_lock(&self, ref_id: Uuid) -> Arc> { - // Fast path: read lock. - { - let locks = self.modifying_current_snapshot.read().await; - if let Some(lock) = locks.get(&ref_id) { - return lock.clone(); - } - } - // Slow path: write lock to insert. - let mut locks = self.modifying_current_snapshot.write().await; - locks.entry(ref_id).or_insert_with(|| Arc::new(Mutex::new(()))).clone() - } -} - /// Context available to RPC procedures. #[derive(Clone)] pub struct AppCtx { diff --git a/packages/backend/src/autosave.rs b/packages/backend/src/autosave.rs deleted file mode 100644 index ec5460031..000000000 --- a/packages/backend/src/autosave.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Autosave listener that persists Automerge document changes to the database. - -use std::time::Duration; - -use crate::app::AppState; -use crate::document::create_snapshot; -use futures_util::stream::StreamExt; -use samod::DocHandle; -use uuid::Uuid; - -const SNAPSHOT_DEBOUNCE_MS: u64 = 500; - -/// Spawns a background task that listens for document changes and triggers autosave. -pub async fn ensure_autosave_listener(state: AppState, ref_id: Uuid, doc_handle: DocHandle) { - let listeners = state.active_listeners.read().await; - if listeners.contains(&ref_id) { - return; - } - - // Explicitly drop the read lock before acquiring write lock - drop(listeners); - - let mut listeners = state.active_listeners.write().await; - listeners.insert(ref_id); - - tokio::spawn({ - let state = state.clone(); - async move { - let mut changes = doc_handle.changes(); - let mut snapshot_handle: Option> = None; - - while (changes.next().await).is_some() { - if let Some(handle) = snapshot_handle.take() { - handle.abort(); - } - let lock = state.snapshot_lock(ref_id).await; - let _guard = match lock.try_lock() { - Ok(guard) => guard, - Err(_) => { - // we can't acquire the lock ignore this change - continue; - } - }; - snapshot_handle = Some(tokio::spawn({ - let state = state.clone(); - async move { - tokio::time::sleep(Duration::from_millis(SNAPSHOT_DEBOUNCE_MS)).await; - let lock = state.snapshot_lock(ref_id).await; - let _guard = match lock.try_lock() { - Ok(guard) => guard, - Err(_) => { - // we can't acquire the lock ignore this change - return; - } - }; - if let Err(e) = create_snapshot(state, ref_id).await { - tracing::error!("Snapshot failed for ref {}: {:?}", ref_id, e); - } - } - })); - } - - state.active_listeners.write().await.remove(&ref_id); - tracing::error!("Autosave listener stopped for ref {}", ref_id); - } - }); -} diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 5468a8fea..5efc2b38c 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -1,7 +1,7 @@ //! Procedures to create and manipulate documents. use crate::app::{AppCtx, AppError, AppState}; -use crate::autosave::ensure_autosave_listener; +use crate::ref_actor::ensure_ref_actor; use crate::user_state_updates::{update_ref_for_users, update_user_state}; use chrono::{DateTime, Utc}; use notebook_types::automerge_json::{hydrate_to_json, populate_automerge_from_json}; @@ -85,7 +85,7 @@ pub async fn new_ref(ctx: AppCtx, content: Value) -> Result { txn.commit().await?; - ensure_autosave_listener(ctx.state.clone(), ref_id, doc_handle).await; + ensure_ref_actor(ctx.state.clone(), ref_id, doc_handle).await; // Update the creating user's state from the database. if let Some(ref uid) = user_id @@ -186,17 +186,20 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr Ok(()) } -/// Sets the current snapshot for a ref by applying the snapshot's state to the -/// live Automerge document. +/// Navigate a live Automerge document to a different snapshot's state. /// /// The document is updated in-place: the target snapshot's state is read from /// the Automerge history via its stored heads, then applied as new operations /// (delete all root keys + repopulate). The `doc_id` is unchanged so connected /// clients receive the update via normal Automerge sync. -pub async fn set_current_snapshot( - state: AppState, +/// +/// The caller is responsible for suppressing autosave (the snapshot actor does +/// this via its `skip_changes` counter). +pub async fn navigate_to_snapshot( + state: &AppState, ref_id: Uuid, snapshot_id: i32, + doc_handle: &samod::DocHandle, ) -> Result<(), AppError> { let snapshot = sqlx::query!( "SELECT heads FROM snapshots WHERE id = $1 AND for_ref = $2", @@ -213,18 +216,6 @@ pub async fn set_current_snapshot( .map(|h| automerge::ChangeHash(h.as_slice().try_into().expect("invalid change hash"))) .collect(); - let doc_id = get_doc_id(state.clone(), ref_id).await?; - let doc_handle = state - .repo - .find(doc_id) - .await? - .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - - // Acquire the per-ref lock. This waits if autosave currently holds it, and while held, - // autosave will not be able to acquire it and will skip changes - let lock = state.snapshot_lock(ref_id).await; - let _guard = lock.lock().await; - doc_handle.with_document(|doc| -> Result<(), AppError> { doc.transact::<_, _, automerge::AutomergeError>(|tx| { copy_doc_at_heads(tx, &target_heads)?; @@ -239,8 +230,8 @@ pub async fn set_current_snapshot( .execute(&state.db) .await?; - if let Err(e) = update_ref_for_users(&state, ref_id, vec![]).await { - tracing::error!(%ref_id, error = %e, "Failed to update user states after set_current_snapshot"); + if let Err(e) = update_ref_for_users(state, ref_id, vec![]).await { + tracing::error!(%ref_id, error = %e, "Failed to update user states after navigate_to_snapshot"); } Ok(()) diff --git a/packages/backend/src/lib.rs b/packages/backend/src/lib.rs index 23a85b086..628afa7c0 100644 --- a/packages/backend/src/lib.rs +++ b/packages/backend/src/lib.rs @@ -6,8 +6,8 @@ pub mod app; /// Authentication and authorization for document refs. pub mod auth; -/// Autosave listener for document changes. -pub mod autosave; +/// Per-ref actor for autosave, navigation, and lifecycle operations. +pub mod ref_actor; /// Autosurgeon utilities for datetime serialization. pub mod autosurgeon_datetime; diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index 8c36df39e..69976a60a 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -11,7 +11,7 @@ use sqlx::postgres::PgPoolOptions; use sqlx_migrator::cli::MigrationCommand; use sqlx_migrator::migrator::{Migrate, Migrator}; use sqlx_migrator::{Info, Plan}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use tokio::sync::RwLock; @@ -136,8 +136,7 @@ async fn main() { let state = app::AppState { db: db.clone(), repo, - active_listeners: Arc::new(RwLock::new(HashSet::new())), - modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), + ref_actors: Arc::new(RwLock::new(HashMap::new())), initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client, julia_url, diff --git a/packages/backend/src/ref_actor.rs b/packages/backend/src/ref_actor.rs new file mode 100644 index 000000000..a763a0615 --- /dev/null +++ b/packages/backend/src/ref_actor.rs @@ -0,0 +1,122 @@ +//! Per-ref actor that coordinates changes to a document ref's state. + +use std::time::Duration; + +use crate::app::{AppError, AppState, RefMsg, RefReply}; +use crate::document; +use futures_util::stream::StreamExt; +use samod::DocHandle; +use tokio::sync::mpsc; +use tokio::time::Instant; +use uuid::Uuid; + +const SNAPSHOT_DEBOUNCE: Duration = Duration::from_millis(500); + +/// Ensures a ref actor is running for the given ref, spawning one if needed. +pub async fn ensure_ref_actor(state: AppState, ref_id: Uuid, doc_handle: DocHandle) { + let mut actors = state.ref_actors.write().await; + if actors.contains_key(&ref_id) { + return; + } + + let (tx, rx) = mpsc::channel(8); + actors.insert(ref_id, tx); + drop(actors); + + tokio::spawn(run_ref_actor(state, ref_id, doc_handle, rx)); +} + +/// Send a message to the ref actor for `ref_id`. +/// +/// Returns an error if no actor is running. +pub async fn send_to_actor(state: &AppState, ref_id: Uuid, msg: RefMsg) -> Result<(), AppError> { + let tx = state + .ref_actors + .read() + .await + .get(&ref_id) + .cloned() + .ok_or_else(|| AppError::Invalid(format!("No ref actor running for {ref_id}")))?; + + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + + tx.send((msg, reply_tx)) + .await + .map_err(|_| AppError::Invalid(format!("Ref actor for {ref_id} stopped")))?; + + reply_rx + .await + .map_err(|_| AppError::Invalid(format!("Ref actor for {ref_id} dropped reply")))? +} + +/// The main actor loop for a single document ref. +async fn run_ref_actor( + state: AppState, + ref_id: Uuid, + doc_handle: DocHandle, + mut rx: mpsc::Receiver<(RefMsg, RefReply)>, +) { + let mut changes = doc_handle.changes(); + let mut deadline: Option = None; + let mut skip_changes: u32 = 0; + + loop { + let sleep = match deadline { + Some(d) => tokio::time::sleep_until(d), + None => tokio::time::sleep(Duration::MAX), + }; + tokio::pin!(sleep); + + tokio::select! { + biased; + + Some((msg, reply)) = rx.recv() => { + let result = match msg { + RefMsg::CreateSnapshot => { + deadline = None; + document::create_snapshot(state.clone(), ref_id).await + } + RefMsg::SetCurrentSnapshot { snapshot_id } => { + deadline = None; + skip_changes += 1; + document::navigate_to_snapshot( + &state, ref_id, snapshot_id, &doc_handle, + ).await + } + RefMsg::Delete => { + deadline = None; + document::delete_ref(state.clone(), ref_id).await + } + RefMsg::Restore => { + deadline = None; + document::restore_ref(state.clone(), ref_id).await + } + }; + + let _ = reply.send(result); + } + + change = changes.next() => { + if change.is_none() { + break; + } + + if skip_changes > 0 { + skip_changes -= 1; + continue; + } + + deadline = Some(Instant::now() + SNAPSHOT_DEBOUNCE); + } + + _ = &mut sleep => { + deadline = None; + if let Err(e) = document::create_snapshot(state.clone(), ref_id).await { + tracing::error!("Autosave snapshot failed for ref {}: {:?}", ref_id, e); + } + } + } + } + + state.ref_actors.write().await.remove(&ref_id); +} diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index 98377137e..fe6845959 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -6,9 +6,9 @@ use serde_json::Value; use tracing::debug; use uuid::Uuid; -use super::app::{AppCtx, AppError, AppState}; +use super::app::{AppCtx, AppError, AppState, RefMsg}; use super::auth::{NewPermissions, PermissionLevel, Permissions}; -use super::autosave::ensure_autosave_listener; +use super::ref_actor::{ensure_ref_actor, send_to_actor}; use super::user_state::get_or_create_user_state_doc; use super::{auth, document as doc, user}; @@ -54,7 +54,7 @@ async fn get_doc(ctx: AppCtx, ref_id: Uuid) -> RpcResult { .find(doc_id.clone()) .await? .ok_or_else(|| AppError::Invalid("Document not found".to_string()))?; - ensure_autosave_listener(ctx.state.clone(), ref_id, doc_handle).await; + ensure_ref_actor(ctx.state.clone(), ref_id, doc_handle).await; Ok(RefDoc::Live { doc_id: doc_id.to_string(), is_deleted, @@ -109,7 +109,7 @@ async fn head_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult { async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async { auth::authorize(&ctx, ref_id, PermissionLevel::Write).await?; - doc::create_snapshot(ctx.state, ref_id).await + send_to_actor(&ctx.state, ref_id, RefMsg::CreateSnapshot).await } .await .into() @@ -119,7 +119,7 @@ async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async fn set_current_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> RpcResult<()> { async { auth::authorize(&ctx, ref_id, PermissionLevel::Write).await?; - doc::set_current_snapshot(ctx.state, ref_id, snapshot_id).await + send_to_actor(&ctx.state, ref_id, RefMsg::SetCurrentSnapshot { snapshot_id }).await } .await .into() @@ -129,7 +129,7 @@ async fn set_current_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> Rp async fn delete_ref(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async { auth::authorize(&ctx, ref_id, PermissionLevel::Own).await?; - doc::delete_ref(ctx.state, ref_id).await + send_to_actor(&ctx.state, ref_id, RefMsg::Delete).await } .await .into() @@ -139,7 +139,7 @@ async fn delete_ref(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async fn restore_ref(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async { auth::authorize(&ctx, ref_id, PermissionLevel::Own).await?; - doc::restore_ref(ctx.state, ref_id).await + send_to_actor(&ctx.state, ref_id, RefMsg::Restore).await } .await .into() diff --git a/packages/backend/tests/common/test_utils.rs b/packages/backend/tests/common/test_utils.rs index a8d4c7ead..e483012cd 100644 --- a/packages/backend/tests/common/test_utils.rs +++ b/packages/backend/tests/common/test_utils.rs @@ -4,7 +4,7 @@ use serde_json::json; use sqlx::PgPool; use sqlx_migrator::migrator::{Migrate, Migrator}; use sqlx_migrator::{Info, Plan}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -45,11 +45,10 @@ pub async fn create_test_app_state(pool: PgPool) -> AppState { AppState { db: pool, repo, - active_listeners: Arc::new(RwLock::new(HashSet::new())), + ref_actors: Arc::new(RwLock::new(HashMap::new())), initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, - modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), } } diff --git a/packages/backend/tests/user_state_tests.rs b/packages/backend/tests/user_state_tests.rs index cf59c9817..de022b767 100644 --- a/packages/backend/tests/user_state_tests.rs +++ b/packages/backend/tests/user_state_tests.rs @@ -1090,7 +1090,7 @@ mod integration_tests { use autosurgeon::hydrate; use backend::app::AppState; use backend::storage::PostgresStorage; - use std::collections::{HashMap, HashSet}; + use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -1107,11 +1107,10 @@ mod integration_tests { let state = AppState { db: test_db.pool().clone(), repo, - active_listeners: Arc::new(RwLock::new(HashSet::new())), + ref_actors: Arc::new(RwLock::new(HashMap::new())), initialized_user_states: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), julia_url: None, - modifying_current_snapshot: Arc::new(RwLock::new(HashMap::new())), }; let expected_state = From 05afd8fc44a26874e548c6270269eec1499c654e Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Thu, 2 Apr 2026 10:54:20 +0100 Subject: [PATCH 27/38] CLEANUP: Remove head_snapshot RPC method --- packages/backend/src/document.rs | 13 ---------- packages/backend/src/rpc.rs | 11 -------- .../frontend/src/api/document_editing.test.ts | 25 ++++++++----------- .../frontend/src/api/document_rpc.test.ts | 14 +++-------- 4 files changed, 15 insertions(+), 48 deletions(-) diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index 5efc2b38c..d5c84ce76 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -98,19 +98,6 @@ pub async fn new_ref(ctx: AppCtx, content: Value) -> Result { Ok(ref_id) } -/// Gets the content of the head snapshot for a document ref. -pub async fn head_snapshot(state: AppState, ref_id: Uuid) -> Result { - let query = sqlx::query!( - " - SELECT content FROM snapshots - WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) - ", - ref_id - ); - - Ok(query.fetch_one(&state.db).await?.content) -} - /// Gets the binary automerge data for a document ref. pub async fn head_snapshot_binary(state: AppState, ref_id: Uuid) -> Result { let doc_id = get_doc_id(state.clone(), ref_id).await?; diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index fe6845959..30549be9d 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -17,7 +17,6 @@ pub fn router() -> Router { Router::new() .handler(new_ref) .handler(get_doc) - .handler(head_snapshot) .handler(create_snapshot) .handler(set_current_snapshot) .handler(delete_ref) @@ -95,16 +94,6 @@ enum RefDoc { }, } -#[handler(query)] -async fn head_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult { - async { - auth::authorize(&ctx, ref_id, PermissionLevel::Read).await?; - doc::head_snapshot(ctx.state, ref_id).await - } - .await - .into() -} - #[handler(mutation)] async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { async { diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index 1878ea4fb..e6fc9dd63 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -271,7 +271,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { // --------------------------------------------------------------- // Test 6: set_current_snapshot updates the database snapshot content // --------------------------------------------------------------- - test.sequential("should update head_snapshot content after navigating to an older snapshot", async () => { + test.sequential("should update document content after navigating to an older snapshot", async () => { await signInWithEmailAndPassword(auth, email, password); const originalName = `DB Revert Original - ${v4()}`; @@ -298,30 +298,27 @@ describe("Document editing, snapshots, and undo/redo", async () => { return doc !== undefined && Object.keys(doc.snapshots).length >= 2; }, "Should have two snapshots after autosave"); - // Verify the head_snapshot shows the edited version. - const editedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< - string, - unknown - >; - assert.strictEqual(editedContent.name, editedName, "head_snapshot should show edited name"); + // Verify the live document shows the edited version. + assert.strictEqual(handle.doc()?.name, editedName, "live doc should show edited name"); // Navigate back to original. unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); - // The head_snapshot should now point to the original snapshot's content. + // The current snapshot should now point to the original. await waitFor(() => { const doc = findDoc(refId); return doc !== undefined && doc.currentSnapshot === originalSnapshotId; }, "currentSnapshot should point to original"); - const revertedContent = unwrap(await rpc.head_snapshot.query(refId)) as Record< - string, - unknown - >; + // The live document should be reverted to the original content. + await waitFor( + () => handle.doc()?.name === originalName, + "live doc should show original name after revert", + ); assert.strictEqual( - revertedContent.name, + handle.doc()?.name, originalName, - "head_snapshot should show original name after revert", + "live doc should show original name after revert", ); }); diff --git a/packages/frontend/src/api/document_rpc.test.ts b/packages/frontend/src/api/document_rpc.test.ts index 84a4ada65..1da129d9f 100644 --- a/packages/frontend/src/api/document_rpc.test.ts +++ b/packages/frontend/src/api/document_rpc.test.ts @@ -100,7 +100,8 @@ describe("RPC for Automerge documents", async () => { }); test.sequential("should autosave to the database", { timeout: 1000, retry: 5 }, async () => { - const newContent = unwrap(await rpc.head_snapshot.query(refId)) as unknown as Document; + const newContent = docHandle.doc(); + assert(newContent); assert.strictEqual(newContent.name, newName); }); }); @@ -122,11 +123,6 @@ describe("Authorized RPC", async () => { assert(uuid.validate(privateId)); }); - const fetchedContent = unwrap(await rpc.head_snapshot.query(privateId)); - test.sequential("should get document content when authenticated", () => { - assert.deepStrictEqual(fetchedContent, content); - }); - const refDoc = unwrap(await rpc.get_doc.query(privateId)); test.sequential("should get a live document when authenticated", () => { assert(refDoc.tag === "Live"); @@ -148,11 +144,9 @@ describe("Authorized RPC", async () => { await signOut(auth); - const forbiddenResult1 = await rpc.head_snapshot.query(privateId); - const forbiddenResult2 = await rpc.get_doc.query(privateId); + const forbiddenResult = await rpc.get_doc.query(privateId); test.sequential("should prohibit document access when unauthenticated", () => { - assert.strictEqual(unwrapErr(forbiddenResult1).code, 403); - assert.strictEqual(unwrapErr(forbiddenResult2).code, 403); + assert.strictEqual(unwrapErr(forbiddenResult).code, 403); }); const readonlyDoc = unwrap(await rpc.get_doc.query(readonlyId)); From 98ce93e6e128efccf4c47a11e748b4b7df456dde Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Thu, 2 Apr 2026 10:58:01 +0100 Subject: [PATCH 28/38] REFACTOR: Rename set_current_snapshot to load_snapshot --- packages/backend/src/app.rs | 2 +- packages/backend/src/document.rs | 6 ++-- packages/backend/src/ref_actor.rs | 4 +-- packages/backend/src/rpc.rs | 6 ++-- .../frontend/src/api/document_editing.test.ts | 34 +++++++++---------- packages/frontend/src/api/user_state.test.ts | 4 +-- .../frontend/src/page/use_snapshot_history.ts | 2 +- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/app.rs b/packages/backend/src/app.rs index 033854993..654da347f 100644 --- a/packages/backend/src/app.rs +++ b/packages/backend/src/app.rs @@ -18,7 +18,7 @@ pub enum RefMsg { /// Request an immediate snapshot (manual save / RPC call). CreateSnapshot, /// Set the current snapshot for the document ref. - SetCurrentSnapshot { + LoadSnapshot { /// The target snapshot to set as current. snapshot_id: i32, }, diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index d5c84ce76..e83cf7d31 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -173,7 +173,7 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr Ok(()) } -/// Navigate a live Automerge document to a different snapshot's state. +/// Set a live Automerge document to a different snapshot's state. /// /// The document is updated in-place: the target snapshot's state is read from /// the Automerge history via its stored heads, then applied as new operations @@ -182,7 +182,7 @@ pub async fn create_snapshot(state: AppState, ref_id: Uuid) -> Result<(), AppErr /// /// The caller is responsible for suppressing autosave (the snapshot actor does /// this via its `skip_changes` counter). -pub async fn navigate_to_snapshot( +pub async fn load_snapshot( state: &AppState, ref_id: Uuid, snapshot_id: i32, @@ -218,7 +218,7 @@ pub async fn navigate_to_snapshot( .await?; if let Err(e) = update_ref_for_users(state, ref_id, vec![]).await { - tracing::error!(%ref_id, error = %e, "Failed to update user states after navigate_to_snapshot"); + tracing::error!(%ref_id, error = %e, "Failed to update user states after load_snapshot"); } Ok(()) diff --git a/packages/backend/src/ref_actor.rs b/packages/backend/src/ref_actor.rs index a763a0615..a7630fd20 100644 --- a/packages/backend/src/ref_actor.rs +++ b/packages/backend/src/ref_actor.rs @@ -76,10 +76,10 @@ async fn run_ref_actor( deadline = None; document::create_snapshot(state.clone(), ref_id).await } - RefMsg::SetCurrentSnapshot { snapshot_id } => { + RefMsg::LoadSnapshot { snapshot_id } => { deadline = None; skip_changes += 1; - document::navigate_to_snapshot( + document::load_snapshot( &state, ref_id, snapshot_id, &doc_handle, ).await } diff --git a/packages/backend/src/rpc.rs b/packages/backend/src/rpc.rs index 30549be9d..a1ce18074 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -18,7 +18,7 @@ pub fn router() -> Router { .handler(new_ref) .handler(get_doc) .handler(create_snapshot) - .handler(set_current_snapshot) + .handler(load_snapshot) .handler(delete_ref) .handler(restore_ref) .handler(get_permissions) @@ -105,10 +105,10 @@ async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { } #[handler(mutation)] -async fn set_current_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> RpcResult<()> { +async fn load_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> RpcResult<()> { async { auth::authorize(&ctx, ref_id, PermissionLevel::Write).await?; - send_to_actor(&ctx.state, ref_id, RefMsg::SetCurrentSnapshot { snapshot_id }).await + send_to_actor(&ctx.state, ref_id, RefMsg::LoadSnapshot { snapshot_id }).await } .await .into() diff --git a/packages/frontend/src/api/document_editing.test.ts b/packages/frontend/src/api/document_editing.test.ts index e6fc9dd63..ff8a5618a 100644 --- a/packages/frontend/src/api/document_editing.test.ts +++ b/packages/frontend/src/api/document_editing.test.ts @@ -221,7 +221,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }); // --------------------------------------------------------------- - // Test 5: set_current_snapshot reverts the live document content + // Test 5: load_snapshot reverts the live document content // --------------------------------------------------------------- test.sequential("should revert live document content when navigating to an older snapshot", async () => { await signInWithEmailAndPassword(auth, email, password); @@ -253,7 +253,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }, "Should have two snapshots after autosave"); // Navigate back to the original snapshot. - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, originalSnapshotId)); // The live Automerge document should revert to the original content. await waitFor( @@ -269,7 +269,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }); // --------------------------------------------------------------- - // Test 6: set_current_snapshot updates the database snapshot content + // Test 6: load_snapshot updates the database snapshot content // --------------------------------------------------------------- test.sequential("should update document content after navigating to an older snapshot", async () => { await signInWithEmailAndPassword(auth, email, password); @@ -302,7 +302,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual(handle.doc()?.name, editedName, "live doc should show edited name"); // Navigate back to original. - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, originalSnapshotId)); // The current snapshot should now point to the original. await waitFor(() => { @@ -375,7 +375,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert(snapshot3Id !== snapshot2Id); // Navigate back to V1 - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot1Id)); await waitFor( () => handle.doc().name === name1, @@ -384,7 +384,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual(handle.doc().name, name1); // Navigate forward to V2 - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot2Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot2Id)); await waitFor( () => handle.doc().name === name2, @@ -393,7 +393,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual(handle.doc().name, name2); // Navigate forward to V3 - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot3Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot3Id)); await waitFor( () => handle.doc().name === name3, @@ -437,7 +437,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { const snapshot2Id = after.currentSnapshot; // Undo: navigate to parent (snapshot 1) - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot1Id)); await waitFor( () => handle.doc().name === name1, @@ -446,7 +446,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { assert.strictEqual(handle.doc().name, name1, "After undo, name should be V1"); // Redo: navigate back to child (snapshot 2) - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot2Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot2Id)); await waitFor( () => handle.doc().name === name2, @@ -486,7 +486,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }, "Should have two snapshots"); // Undo to V1 - unwrap(await rpc.set_current_snapshot.mutate(refId, snapshot1Id)); + unwrap(await rpc.load_snapshot.mutate(refId, snapshot1Id)); await waitFor( () => handle.doc().name === name1, @@ -554,7 +554,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }, "Should have two snapshots after edit"); // Revert to original - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, originalSnapshotId)); await waitFor( () => handle.doc().theory === "empty", @@ -572,7 +572,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }); // --------------------------------------------------------------- - // Test 11: set_current_snapshot should NOT create a spurious snapshot + // Test 11: load_snapshot should NOT create a spurious snapshot // --------------------------------------------------------------- test.sequential( "should not create extra snapshots when navigating to a historical snapshot", @@ -612,7 +612,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { "Should have exactly two snapshots before revert", ); - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, originalSnapshotId)); await waitFor( () => handle.doc().name === name, @@ -710,7 +710,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }, "Should have three snapshots after second edit"); // Navigate back to the snapshot that had marks. - unwrap(await rpc.set_current_snapshot.mutate(refId, markedSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, markedSnapshotId)); await waitFor( () => handle.doc().name === name, @@ -831,7 +831,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { const editedSnapshotId = afterEdit.currentSnapshot; // Undo: navigate back to snapshot 2 (before cell A edit). - unwrap(await rpc.set_current_snapshot.mutate(refId, twoCellSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, twoCellSnapshotId)); await waitFor( () => contentOf(cellA) === "Alpha content", "Undo should revert cell A to original", @@ -849,7 +849,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { ); // Redo: navigate forward to snapshot 3 (cell A edited). - unwrap(await rpc.set_current_snapshot.mutate(refId, editedSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, editedSnapshotId)); await waitFor( () => contentOf(cellA) === "Alpha edited!", "Redo should restore cell A edit", @@ -940,7 +940,7 @@ describe("Document editing, snapshots, and undo/redo", async () => { }, "Should have three snapshots"); // Undo: navigate back to the snapshot with the block marker. - unwrap(await rpc.set_current_snapshot.mutate(refId, withBlockSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, withBlockSnapshotId)); await waitFor(() => handle.doc().name === name, `Should revert to original name`); // The critical check: spans must still be structural, not diff --git a/packages/frontend/src/api/user_state.test.ts b/packages/frontend/src/api/user_state.test.ts index e9ac4308f..0c4264289 100644 --- a/packages/frontend/src/api/user_state.test.ts +++ b/packages/frontend/src/api/user_state.test.ts @@ -254,7 +254,7 @@ describe("User state Automerge document", async () => { assert.strictEqual(updatedDoc.name, newName, "Name should be updated"); }); - test("should switch current snapshot via set_current_snapshot", async () => { + test("should switch current snapshot via load_snapshot", async () => { await signInWithEmailAndPassword(auth, email, password); const name = `Test Snapshot Switch - ${v4()}`; @@ -304,7 +304,7 @@ describe("User state Automerge document", async () => { "Should have two snapshots", ); - unwrap(await rpc.set_current_snapshot.mutate(refId, originalSnapshotId)); + unwrap(await rpc.load_snapshot.mutate(refId, originalSnapshotId)); await waitFor(() => { const doc = findDoc(refId); diff --git a/packages/frontend/src/page/use_snapshot_history.ts b/packages/frontend/src/page/use_snapshot_history.ts index a739be800..cfc4cf3dd 100644 --- a/packages/frontend/src/page/use_snapshot_history.ts +++ b/packages/frontend/src/page/use_snapshot_history.ts @@ -95,7 +95,7 @@ export function useSnapshotHistory(refId: Accessor): SnapshotHistory { const navigate = (snapshotId: string) => { const id = Number.parseInt(snapshotId, 10); if (!Number.isNaN(id)) { - void api.rpc.set_current_snapshot.mutate(refId(), id); + void api.rpc.load_snapshot.mutate(refId(), id); } }; From 225eaabee0fbbd86beaaa78fe334d5f7ed771e28 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Thu, 2 Apr 2026 11:08:34 +0100 Subject: [PATCH 29/38] ENH: Optimistic update of blue dot in history navigator --- .../ui-components/src/history_navigator.tsx | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/ui-components/src/history_navigator.tsx b/packages/ui-components/src/history_navigator.tsx index a3e324c7a..8efaf7f45 100644 --- a/packages/ui-components/src/history_navigator.tsx +++ b/packages/ui-components/src/history_navigator.tsx @@ -82,8 +82,19 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { const timer = setInterval(() => setNow(Date.now()), 30_000); onCleanup(() => clearInterval(timer)); + // Optimistic selection: when the user clicks a row, we immediately show + // the dot there before the parent has had a chance to update `items`. + const [optimisticId, setOptimisticId] = createSignal(null); + + // Clear optimistic override whenever the upstream active item changes. + createEffect(() => { + void props.items.find((it) => it.active)?.id; + setOptimisticId(null); + }); + const displayItems = createMemo(() => { const currentNow = now(); + const pending = optimisticId(); const raw = props.items.map((item) => ({ ...item, minuteKey: Math.floor(item.createdAt / 60_000), @@ -111,13 +122,16 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { } } - return raw.map((item, i) => ({ - id: item.id, - active: item.active, - timestamp: formatRelativeTime(item.createdAt, currentNow), - exactTimestamp: formatExactTimestamp(item.createdAt), - suffix: suffixByIndex.get(i) ?? null, - })); + return raw.map((item, i) => { + const isActive = pending != null ? item.id === pending : item.active; + return { + id: item.id, + active: isActive, + timestamp: formatRelativeTime(item.createdAt, currentNow), + exactTimestamp: formatExactTimestamp(item.createdAt), + suffix: suffixByIndex.get(i) ?? null, + }; + }); }); const activeIndex = createMemo(() => { @@ -172,14 +186,30 @@ export function HistoryNavigator(props: HistoryNavigatorProps) {
{ + const idx = activeIndex(); + const items = displayItems(); + const next = items[idx + 1]; + if (next) { + setOptimisticId(next.id); + } + props.onUndo(); + }} disabled={!props.canUndo} tooltip={props.undoTooltip ?? "Undo"} > { + const idx = activeIndex(); + const items = displayItems(); + const prev = items[idx - 1]; + if (prev) { + setOptimisticId(prev.id); + } + props.onRedo(); + }} disabled={!props.canRedo} tooltip={props.redoTooltip ?? "Redo"} > @@ -207,7 +237,10 @@ export function HistoryNavigator(props: HistoryNavigatorProps) { type="button" class={styles.row} style={{ height: `${ROW_HEIGHT}px` }} - onClick={() => props.onSelect(item.id)} + onClick={() => { + setOptimisticId(item.id); + props.onSelect(item.id); + }} >