diff --git a/Cargo.lock b/Cargo.lock index fc6a9d002..cb5b3c501 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]] @@ -2673,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/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-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json b/packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json similarity index 57% rename from packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json rename to packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json index baac83349..ee8a62b92 100644 --- a/packages/backend/.sqlx/query-6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab.json +++ b/packages/backend/.sqlx/query-191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT doc_id FROM snapshots\n WHERE id = (SELECT head FROM refs WHERE id = $1)\n ", + "query": "\n SELECT doc_id FROM refs WHERE id = $1\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "6425b7d75c5fd6482701adba8011472860299d4fc872194449e18682cfed5bab" + "hash": "191ac883b2f873798c86287219d5459d4a70a0349acab02a98708ff9615108c0" } 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-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json b/packages/backend/.sqlx/query-48cef3b6ffd5a82567119dfd1030042fe9754041452b2c107aa58bde7d81ecf6.json similarity index 52% rename from packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json rename to packages/backend/.sqlx/query-48cef3b6ffd5a82567119dfd1030042fe9754041452b2c107aa58bde7d81ecf6.json index f93a4542b..5eeb0e499 100644 --- a/packages/backend/.sqlx/query-7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d.json +++ b/packages/backend/.sqlx/query-48cef3b6ffd5a82567119dfd1030042fe9754041452b2c107aa58bde7d81ecf6.json @@ -1,6 +1,6 @@ { "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 ", + "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 refs.current_snapshot_updated_at AS \"current_snapshot_updated_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": [ { @@ -35,13 +35,28 @@ }, { "ordinal": 6, + "name": "current_snapshot_updated_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, "name": "content!", "type_info": "Jsonb" }, { - "ordinal": 7, + "ordinal": 8, + "name": "current_snapshot!", + "type_info": "Int4" + }, + { + "ordinal": 9, "name": "permissions!: sqlx::types::Json>", "type_info": "Json" + }, + { + "ordinal": 10, + "name": "snapshots!: sqlx::types::Json>", + "type_info": "Json" } ], "parameters": { @@ -57,8 +72,11 @@ false, true, false, + false, + false, + null, null ] }, - "hash": "7590e8e035f9f3f10f9ca3288182ae5906114a973fcbd111f70bfd9fc8b2655d" + "hash": "48cef3b6ffd5a82567119dfd1030042fe9754041452b2c107aa58bde7d81ecf6" } 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-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-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-a7d130b2e440554bafd2029c4e50b32d6795534250832a9187bdd4cd6907c3bc.json b/packages/backend/.sqlx/query-a7d130b2e440554bafd2029c4e50b32d6795534250832a9187bdd4cd6907c3bc.json new file mode 100644 index 000000000..ffdecd471 --- /dev/null +++ b/packages/backend/.sqlx/query-a7d130b2e440554bafd2029c4e50b32d6795534250832a9187bdd4cd6907c3bc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE refs SET current_snapshot = $2, current_snapshot_updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "a7d130b2e440554bafd2029c4e50b32d6795534250832a9187bdd4cd6907c3bc" +} 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/.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/README.md b/packages/backend/README.md index f2d127faf..ece923704 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -65,7 +65,7 @@ pnpm run dev ![Entity-relationship diagram](schema.svg) -https://catcolab.org/analysis/019c28c7-5b0a-7ee3-a83b-6c1d32892047 +https://catcolab.org/analysis/019d7230-d368-7352-b20e-38a72fc3af9b ### permissions @@ -85,7 +85,7 @@ This table refers to our users. We use Firebase so our `id` in this table are Fi ### snapshots -These are timestamped snapshots of the Automerge document. We current only ever use the latest snapshot. +These are timestamped snapshots of the Automerge document. ## Running migrations diff --git a/packages/backend/schema.svg b/packages/backend/schema.svg index 5ac6f2949..049b42084 100644 --- a/packages/backend/schema.svg +++ b/packages/backend/schema.svg @@ -4,147 +4,165 @@ - - + + 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_updated_at           + +timestamp      + +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/app.rs b/packages/backend/src/app.rs index 0d7bc0281..85f0355ab 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::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. + LoadSnapshot { + /// 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,8 +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>>, + /// 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. @@ -49,6 +70,10 @@ pub enum AppError { #[error("SQL database error: {0}")] Db(#[from] sqlx::Error), + /// Resource not found. + #[error("Not found: {0}")] + NotFound(String), + /// Error from the Automerge Repo. #[error("AutomergeRepo error: {0}")] AutomergeRepo(#[from] samod::Stopped), diff --git a/packages/backend/src/document.rs b/packages/backend/src/document.rs index e0290f0d9..d09c26932 100644 --- a/packages/backend/src/document.rs +++ b/packages/backend/src/document.rs @@ -1,11 +1,12 @@ //! 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::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}; +use notebook_types::automerge_util::copy_doc_at_heads; use samod::DocumentId; -use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -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 @@ -49,22 +52,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, doc_id) - VALUES ($1, $2, NOW(), $3) - RETURNING id + INSERT INTO snapshots(for_ref, content, created_at, 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, current_snapshot_updated_at) + VALUES ($1, (SELECT id FROM snapshot), NOW(), $3, NOW()) ", - ref_id, - // Use the JSON provided by automerge as the authoritative content - // serde_json::to_value(doc_content), - content, - doc_id, ) + .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?; @@ -81,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 @@ -94,21 +98,8 @@ 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 head 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 { +pub async fn get_doc_binary_data(state: AppState, ref_id: Uuid) -> Result { let doc_id = get_doc_id(state.clone(), ref_id).await?; let doc_handle = state @@ -140,32 +131,7 @@ 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 } = data; - sqlx::query!( - " - UPDATE snapshots - SET content = $2, last_updated = NOW() - WHERE id = (SELECT head FROM refs WHERE id = $1) - ", - ref_id, - content - ) - .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. +/// 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 doc_id = get_doc_id(state.clone(), ref_id).await?; @@ -175,29 +141,97 @@ 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_handle = state.repo.create(cloned_doc).await?; - - let doc_content = head_snapshot(state.clone(), ref_id).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!( + 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, created_at, heads, parent) + VALUES ($1, $2, NOW(), $3, (SELECT current_snapshot FROM refs WHERE id = $1)) RETURNING id ) UPDATE refs - SET head = (SELECT id FROM snapshot) + SET current_snapshot = (SELECT id FROM snapshot), + current_snapshot_updated_at = NOW() WHERE id = $1 ", - ref_id, - doc_content, - cloned_handle.document_id().to_string(), ) + .bind(ref_id) + .bind(doc_content) + .bind(&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 create_snapshot"); + } + + Ok(()) +} + +/// 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 +/// (delete all root keys + repopulate). The `doc_id` is unchanged so connected +/// clients receive the update via normal Automerge sync. +/// +/// The caller is responsible for suppressing autosave (the snapshot actor does +/// this via its `skip_changes` counter). +pub async fn load_snapshot( + state: &AppState, + ref_id: Uuid, + snapshot_id: i32, + doc_handle: &samod::DocHandle, +) -> Result<(), AppError> { + // Use a transaction to ensure that current_snapshot pointer and the automerge doc stay in sync + let mut db_tx = state.db.begin().await?; + + let snapshot = sqlx::query!( + "SELECT heads FROM snapshots WHERE id = $1 AND for_ref = $2", + snapshot_id, + ref_id, + ) + .fetch_optional(&mut *db_tx) + .await? + .ok_or_else(|| AppError::NotFound(format!("snapshot {snapshot_id} for ref {ref_id}")))?; + + let target_heads: Vec = snapshot + .heads + .iter() + .map(|h: &Vec| { + h.as_slice() + .try_into() + .map(automerge::ChangeHash) + .map_err(|_| AppError::Invalid("invalid change hash in snapshot".to_string())) + }) + .collect::>()?; + + sqlx::query!( + "UPDATE refs SET current_snapshot = $2, current_snapshot_updated_at = NOW() WHERE id = $1", + ref_id, + snapshot_id, + ) + .execute(&mut *db_tx) + .await?; + + doc_handle.with_document(|doc| { + doc.transact::<_, _, automerge::AutomergeError>(|tx| copy_doc_at_heads(tx, &target_heads)) + .map_err(|e| AppError::Automerge(e.error))?; + Ok::<(), AppError>(()) + })?; + + db_tx.commit().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 load_snapshot"); + } + Ok(()) } @@ -245,8 +279,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 ); @@ -256,24 +289,5 @@ pub async fn get_doc_id(state: AppState, ref_id: Uuid) -> Result Result, AppError> { + if let Some(tx) = state.ref_actors.read().await.get(&ref_id).cloned() { + return Ok(tx); + } + + let doc_id = document::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()))?; + + ensure_ref_actor(state.clone(), ref_id, doc_handle).await; + + state + .ref_actors + .read() + .await + .get(&ref_id) + .cloned() + .ok_or_else(|| AppError::Invalid(format!("Failed to start ref actor for {ref_id}"))) +} + +/// Send a message to the ref actor for `ref_id`, starting one if needed. +pub async fn send_to_actor(state: &AppState, ref_id: Uuid, msg: RefMsg) -> Result<(), AppError> { + let tx = get_or_start_actor(state, ref_id).await?; + + 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::LoadSnapshot { snapshot_id } => { + deadline = None; + skip_changes += 1; + document::load_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 d05f0291d..3bcd797dc 100644 --- a/packages/backend/src/rpc.rs +++ b/packages/backend/src/rpc.rs @@ -6,8 +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::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}; @@ -16,8 +17,7 @@ pub fn router() -> Router { Router::new() .handler(new_ref) .handler(get_doc) - .handler(head_snapshot) - .handler(create_snapshot) + .handler(load_snapshot) .handler(delete_ref) .handler(restore_ref) .handler(get_permissions) @@ -45,14 +45,19 @@ 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::NotFound(format!("document {doc_id} for ref {ref_id}")) + })?; + ensure_ref_actor(ctx.state.clone(), ref_id, doc_handle).await; Ok(RefDoc::Live { doc_id: doc_id.to_string(), is_deleted, permissions, }) } else if max_level >= Some(PermissionLevel::Read) { - let binary_data = doc::head_snapshot_binary(ctx.state, ref_id).await?; + let binary_data = doc::get_doc_binary_data(ctx.state, ref_id).await?; Ok(RefDoc::Readonly { binary_data, is_deleted, permissions }) } else { Err(AppError::Forbidden(ref_id)) @@ -86,21 +91,11 @@ 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 fn load_snapshot(ctx: AppCtx, ref_id: Uuid, snapshot_id: i32) -> 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::LoadSnapshot { snapshot_id }).await } .await .into() @@ -110,7 +105,7 @@ async fn create_snapshot(ctx: AppCtx, ref_id: Uuid) -> RpcResult<()> { 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() @@ -120,7 +115,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() @@ -200,7 +195,7 @@ impl From for RpcResult { AppError::Invalid(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Forbidden(_) => StatusCode::FORBIDDEN, - AppError::Db(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, + AppError::NotFound(_) | AppError::Db(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; RpcResult::Err { 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..264cbae76 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 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")] + 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,15 @@ pub struct DocInfo { #[autosurgeon(rename = "deletedAt", with = "option_datetime_millis")] #[ts(type = "number | null")] pub deleted_at: Option>, + /// When the current snapshot pointer was last changed (snapshot created or undo/redo). + #[autosurgeon(rename = "currentSnapshotUpdatedAt", with = "datetime_millis")] + #[ts(type = "number")] + pub current_snapshot_updated_at: chrono::DateTime, + /// The database ID of the current (active) snapshot. + #[autosurgeon(rename = "currentSnapshot")] + pub current_snapshot: i32, + /// 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, @@ -242,7 +267,9 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result>'theory' AS theory, refs.created AS "created_at!", refs.deleted_at, + refs.current_snapshot_updated_at AS "current_snapshot_updated_at!", snapshots.content AS "content!", + refs.current_snapshot AS "current_snapshot!", COALESCE( (SELECT json_agg(json_build_object( 'user', p.subject, @@ -251,10 +278,23 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result>" + ) AS "permissions!: sqlx::types::Json>", + COALESCE( + (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>" 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, @@ -282,6 +322,9 @@ pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result(), 0..5), 0i64..253402300799i64, proptest::option::of(0i64..253402300799i64), + 0i64..253402300799i64, ) - .prop_map(|(name, type_name, theory, permissions, seconds, deleted_seconds)| { - DocInfo { - name: Text::from(name), + .prop_map( + |( + name, type_name, theory, permissions, - created_at: Utc - .timestamp_opt(seconds, 0) - .single() - .expect("valid timestamp"), - deleted_at: deleted_seconds - .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")), - // We are not yet generating complete relationship trees, just independent - // docs - depends_on: Vec::new(), - used_by: Vec::new(), - } - }) + seconds, + deleted_seconds, + updated_seconds, + )| { + DocInfo { + name: Text::from(name), + type_name, + theory, + permissions, + created_at: Utc + .timestamp_opt(seconds, 0) + .single() + .expect("valid timestamp"), + deleted_at: deleted_seconds.map(|s| { + Utc.timestamp_opt(s, 0).single().expect("valid timestamp") + }), + current_snapshot_updated_at: Utc + .timestamp_opt(updated_seconds, 0) + .single() + .expect("valid timestamp"), + current_snapshot: 1, + snapshots: HashMap::new(), + depends_on: Vec::new(), + used_by: Vec::new(), + } + }, + ) .boxed() } } @@ -544,6 +603,7 @@ pub mod arbitrary { any::>(), // other owner display_name 0i64..253402300799i64, // created_at seconds proptest::option::of(0i64..253402300799i64), // deleted_at seconds + 0i64..253402300799i64, // current_snapshot_updated_at seconds ) .prop_map( move |( @@ -555,6 +615,7 @@ pub mod arbitrary { other_owner_display_name, seconds, deleted_seconds, + updated_seconds, )| { let mut permissions = Vec::new(); let mut users = HashMap::new(); @@ -603,6 +664,12 @@ pub mod arbitrary { .expect("valid timestamp"), deleted_at: deleted_seconds .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")), + current_snapshot_updated_at: Utc + .timestamp_opt(updated_seconds, 0) + .single() + .expect("valid timestamp"), + current_snapshot: 1, + snapshots: HashMap::new(), // We are not yet generating complete relationship trees, just independent // docs depends_on: Vec::new(), 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..e483012cd --- /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; +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, + 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, + } +} + +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 e802466a1..098725091 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", @@ -426,12 +360,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 }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query( + r#" + UPDATE snapshots + SET content = $2, created_at = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, ) - .await - .expect("Failed to autosave"); + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) + .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 +419,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 }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query( + r#" + UPDATE snapshots + SET content = $2, created_at = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, ) - .await - .expect("Failed to autosave"); + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) + .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 +481,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 }, + let fake_heads: Vec> = vec![vec![0u8; 32]]; + sqlx::query( + r#" + UPDATE snapshots + SET content = $2, created_at = NOW(), heads = $3 + WHERE id = (SELECT current_snapshot FROM refs WHERE id = $1) + "#, ) - .await - .expect("Failed to autosave"); + .bind(ref_id) + .bind(updated) + .bind(&fake_heads) + .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(); @@ -971,11 +935,14 @@ mod integration_tests { } /// Write a `UserState` to the database (test helper). + /// + /// Returns a copy of the state with `current_snapshot` and `snapshots` + /// populated from the database (since those are generated by the DB). async fn write_user_state_to_db( user_id: String, db: &PgPool, state: &UserState, - ) -> Result<(), AppError> { + ) -> Result { sqlx::query!( r#" INSERT INTO users (id, created, signed_in, username, display_name) @@ -989,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"); @@ -1018,25 +987,32 @@ mod integration_tests { content["theory"] = serde_json::Value::String(theory.clone()); } - sqlx::query!( + let fake_heads: Vec> = vec![vec![0u8; 32]]; + let snapshot_id: i32 = sqlx::query_scalar( 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, created_at, 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, current_snapshot_updated_at) + VALUES ($1, (SELECT id FROM snapshot), $3, $4, $6) + ON CONFLICT (id) DO UPDATE SET current_snapshot = (SELECT id FROM snapshot), + current_snapshot_updated_at = $6 + RETURNING current_snapshot "#, - ref_id, - content, - doc.created_at, - format!("test_fake_automerge_doc_{ref_id}") ) - .execute(db) + .bind(ref_id) + .bind(content) + .bind(doc.created_at) + .bind(format!("test_fake_automerge_doc_{ref_id}")) + .bind(&fake_heads) + .bind(doc.current_snapshot_updated_at) + .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#" @@ -1066,7 +1042,23 @@ 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 = HashMap::from([( + sid.to_string(), + backend::user_state::SnapshotInfo { + parent: None, + created_at, + heads: vec![fake_heads_hex.clone()], + }, + )]); + } + } + Ok(result) } /// Write→read roundtrip through the database. @@ -1077,9 +1069,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 @@ -1087,7 +1080,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 @@ -1099,7 +1092,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; @@ -1116,17 +1109,18 @@ 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, }; - 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"); @@ -1145,10 +1139,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); } } } diff --git a/packages/frontend/default.nix b/packages/frontend/default.nix index 837811159..ffa2911c9 100644 --- a/packages/frontend/default.nix +++ b/packages/frontend/default.nix @@ -36,7 +36,7 @@ let pnpmDeps = pkgs.fetchPnpmDeps { # see ../../dev-docs/fixing-hash-mismatches.md - hash = "sha256-JX+2KQgJ7W32O1N3TB3qMAK18i1NzS4n2QN4uN33Bro="; + hash = "sha256-2Dlp51pk+P9QHQheGTtq2RUbM3V+rZily7/mbK2kCVg="; pname = name; fetcherVersion = 2; 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..adf32716e --- /dev/null +++ b/packages/frontend/src/api/document_editing.test.ts @@ -0,0 +1,940 @@ +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"; +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); + void 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; + }; + + // --------------------------------------------------------------- + // 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); + }); + + // --------------------------------------------------------------- + // 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"); + }); + + // --------------------------------------------------------------- + // 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", + ); + }); + + // --------------------------------------------------------------- + // 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); + + 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.load_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", + ); + }); + + // --------------------------------------------------------------- + // 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); + + 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 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.load_snapshot.mutate(refId, originalSnapshotId)); + + // 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"); + + // 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( + handle.doc()?.name, + originalName, + "live doc should show original name after revert", + ); + }); + + // --------------------------------------------------------------- + // 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.load_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.load_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.load_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); + }); + + // --------------------------------------------------------------- + // 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.load_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.load_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"); + }); + + // --------------------------------------------------------------- + // 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.load_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"); + }); + + // --------------------------------------------------------------- + // 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", + 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"); + + // Revert to original + unwrap(await rpc.load_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", + ); + }); + + // --------------------------------------------------------------- + // load_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.load_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", + ); + }, + ); + + // --------------------------------------------------------------- + // 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: "", + }; + 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"]; + 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); + 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.load_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); + 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); + }, + ); + + // --------------------------------------------------------------- + // 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: "", + }; + 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: "", + }; + Automerge.splice( + doc, + ["notebook", "cellContents", cellB, "content"], + 0, + 0, + "Beta content", + ); + }); + + 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"); + + // 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.load_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.load_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)}"`, + ); + }, + ); + + // --------------------------------------------------------------- + // 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: "", + }; + Automerge.splitBlock(doc, contentPath, 0, { + type: "paragraph", + }); + Automerge.splice(doc, contentPath, 1, 0, "hello 1"); + }); + + 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]!.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.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 + // literal U+FFFC characters in the text. + const spansAfter = Automerge.spans(handle.doc(), contentPath); + 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/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)); diff --git a/packages/frontend/src/api/user_state.test.ts b/packages/frontend/src/api/user_state.test.ts index fe16a85b3..0c4264289 100644 --- a/packages/frontend/src/api/user_state.test.ts +++ b/packages/frontend/src/api/user_state.test.ts @@ -253,4 +253,75 @@ 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 load_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"); + 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, + "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 && Object.keys(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( + Object.keys(afterAutosave.snapshots).length, + 2, + "Should have two snapshots", + ); + + unwrap(await rpc.load_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( + Object.keys(reverted.snapshots).length, + 2, + "Both snapshots should still be present", + ); + }); }); 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..413c6cc1a 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 ( @@ -327,6 +340,12 @@ function richTextEditorKeymap(schema: CustomSchema, props: RichTextEditorOptions bindings["Mod-i"] = toggleMark(schema.marks.em); bindings["Mod-l"] = insertLinkCmd; bindings["Mod-m"] = insertMathDisplayCmd; + + // Block browser native undo/redo to prevent contenteditable undo from + // desynchronizing ProseMirror state with the DOM. + bindings["Mod-z"] = () => true; + bindings["Mod-y"] = () => true; + bindings["Mod-Shift-z"] = () => true; bindings["Backspace"] = chainCommands( deleteSelection, mathBackspaceCmd, @@ -476,3 +495,27 @@ 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/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..4629ad7b0 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,8 @@ 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 { useSnapshotHistory } from "./use_snapshot_history"; import "./document_page.css"; @@ -104,6 +107,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 +158,8 @@ export default function DocumentPage() { panelSizes={resizableContext()?.sizes()} maximizeSidePanel={maximizeSidePanel} closeSidePanel={closeSidePanel} + togglePrimaryHistorySidebar={togglePrimaryHistorySidebar} + toggleSecondaryHistorySidebar={toggleSecondaryHistorySidebar} /> } sidebarContents={ @@ -181,6 +191,8 @@ export default function DocumentPage() { refetchPrimaryDoc={refetchPrimaryDoc} refetchSecondaryDoc={refetchSecondaryDoc} setResizableContext={setResizableContext} + primaryHistoryOpen={primaryHistoryOpen()} + secondaryHistoryOpen={secondaryHistoryOpen()} /> )} @@ -197,6 +209,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 +220,9 @@ function SplitPaneToolbar(props: { + + + @@ -213,6 +230,12 @@ function SplitPaneToolbar(props: { class="primary-permissions-toolbar toolbar" style={{ left: `${(primaryPanelSize() ?? 0) * 100}%` }} > + + + @@ -224,6 +247,7 @@ function SplitPaneToolbar(props: { secondaryDocRef={props.secondaryDocRef} closeSidePanel={props.closeSidePanel} maximizeSidePanel={props.maximizeSidePanel} + toggleHistorySidebar={props.toggleSecondaryHistorySidebar} /> )} @@ -237,6 +261,7 @@ function SecondaryToolbar(props: { secondaryDocRef: DocRef | undefined; closeSidePanel: () => void; maximizeSidePanel: () => void; + toggleHistorySidebar: () => void; }) { return ( <> @@ -262,6 +287,9 @@ function SecondaryToolbar(props: { > {(secondary) => (
+ + + void; refetchSecondaryDoc: () => void; setResizableContext: (context: ContextValue) => void; + primaryHistoryOpen: boolean; + secondaryHistoryOpen: boolean; }) { return ( @@ -297,6 +327,7 @@ function ResizablePanels(props: { docRef={props.primaryDocRef} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} + historySidebarOpen={props.primaryHistoryOpen} /> @@ -314,6 +345,7 @@ function ResizablePanels(props: { docRef={secondaryLiveDocWithRef().docRef} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} + historySidebarOpen={props.secondaryHistoryOpen} /> )} @@ -331,6 +363,7 @@ export function DocumentPane(props: { docRef: DocRef; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; + historySidebarOpen: boolean; }) { const api = useApi(); const [isDeleted, setIsDeleted] = createSignal(false); @@ -365,60 +398,72 @@ export function DocumentPane(props: { const canRestore = () => props.docRef.permissions.user === "Own"; + const history = useSnapshotHistory(() => props.docRef.refId); + 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..50b1db463 --- /dev/null +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -0,0 +1,17 @@ +import { HistoryNavigator } from "catcolab-ui-components"; +import type { SnapshotHistory } from "./use_snapshot_history"; + +export function HistorySidebar(props: { history: SnapshotHistory }) { + 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..c0a1a6f8f --- /dev/null +++ b/packages/frontend/src/page/use_snapshot_history.ts @@ -0,0 +1,122 @@ +import type { SnapshotInfo } from "catcolab-api/src/user_state"; +import { type Accessor, createMemo } from "solid-js"; + +import type { HistoryItem } from "catcolab-ui-components"; +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; +} + +/** Find the most recently created direct child of the given snapshot. + +Tiebreaking on equal `createdAt` is nondeterministic, but the ref actor's sequential processing of +messages should guarantee that snapshots are not created in parallel/simultaneously.*/ +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.load_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/frontend/src/user/document_list.tsx b/packages/frontend/src/user/document_list.tsx index bb3cd6230..b871d43b0 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 { RelativeTime, 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"; @@ -33,7 +32,7 @@ export function filterDocuments( if (opts.deleted) { return (b.deletedAt ?? 0) - (a.deletedAt ?? 0); } - return b.createdAt - a.createdAt; + return b.currentSnapshotUpdatedAt - a.currentSnapshotUpdatedAt; }); } @@ -214,11 +213,10 @@ function DocumentRow(props: DocumentRowProps) {
{ownerNames()}
{userPermission()}
- {new Date(props.doc.createdAt).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - })} + +
+
+
{props.actionsPosition === "end" && props.renderActions(props.doc)} diff --git a/packages/frontend/src/user/documents.css b/packages/frontend/src/user/documents.css index 5b434737f..3ec7d4fd5 100644 --- a/packages/frontend/src/user/documents.css +++ b/packages/frontend/src/user/documents.css @@ -34,16 +34,16 @@ align-items: center; } -/* Documents page: 6 columns — Icon / Name / Owners / Permission / Created / Actions */ +/* Documents page: 7 columns — Icon / Name / Owners / Permission / Created / Last edited / Actions */ .documents-page .ref-grid-header, .documents-page .ref-grid-row { - grid-template-columns: 40px 2fr 120px 120px 140px 50px; + grid-template-columns: 40px 2fr 120px 120px 140px 140px 50px; } -/* Trash page: 6 columns — Actions / Icon / Name / Owners / Permission / Created */ +/* Trash page: 7 columns — Actions / Icon / Name / Owners / Permission / Created / Last edited */ .trash-bin-page .ref-grid-header, .trash-bin-page .ref-grid-row { - grid-template-columns: 50px 40px 2fr 120px 120px 140px; + grid-template-columns: 50px 40px 2fr 120px 120px 140px 140px; } /* Adjust for scrollbar in Chromium browsers only */ diff --git a/packages/frontend/src/user/documents.tsx b/packages/frontend/src/user/documents.tsx index 9a893e6ff..f5ce6a569 100644 --- a/packages/frontend/src/user/documents.tsx +++ b/packages/frontend/src/user/documents.tsx @@ -52,6 +52,7 @@ function DocumentsSearch() {
Owners
Permission
Created
+
Last edited
); diff --git a/packages/frontend/src/user/trash.tsx b/packages/frontend/src/user/trash.tsx index 917f0fc86..513c34604 100644 --- a/packages/frontend/src/user/trash.tsx +++ b/packages/frontend/src/user/trash.tsx @@ -52,6 +52,7 @@ function TrashBinSearch() {
Owners
Permission
Created
+
Last edited
); 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/m20260414000000_snapshot_history.rs b/packages/migrator/src/migrations/m20260414000000_snapshot_history.rs new file mode 100644 index 000000000..6d6b99225 --- /dev/null +++ b/packages/migrator/src/migrations/m20260414000000_snapshot_history.rs @@ -0,0 +1,430 @@ +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 SnapshotHistory; + +#[async_trait::async_trait] +impl Migration for SnapshotHistory { + fn app(&self) -> &str { + "backend" + } + + fn name(&self) -> &str { + "m20260414000000_snapshot_history" + } + + fn parents(&self) -> Vec>> { + vec![] + } + + fn operations(&self) -> Vec>> { + vec_box![ + MoveDocIdToRefs, + AddHeadsToSnapshots, + RenameSnapshotTimestamp, + PopulateHeads, + DropDocIdFromSnapshots, + RenameHeadAndDropGetRefStubs, + AddParentToSnapshots, + AddCurrentSnapshotUpdatedAt + ] + } + + 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 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 +/// 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(()) + } +} + +/// 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(()) + } +} + +/// Step 7: Add `current_snapshot_updated_at` to `refs`, tracking when the +/// current snapshot pointer was last changed (snapshot created or undo/redo). +struct AddCurrentSnapshotUpdatedAt; + +#[async_trait::async_trait] +impl Operation for AddCurrentSnapshotUpdatedAt { + async fn up(&self, conn: &mut PgConnection) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + sqlx::query("ALTER TABLE refs ADD COLUMN current_snapshot_updated_at TIMESTAMPTZ") + .execute(&mut *tx) + .await?; + + sqlx::query( + "UPDATE refs SET current_snapshot_updated_at = \ + (SELECT created_at FROM snapshots WHERE id = refs.current_snapshot)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query("ALTER TABLE refs ALTER COLUMN current_snapshot_updated_at 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 current_snapshot_updated_at") + .execute(conn) + .await?; + Ok(()) + } +} diff --git a/packages/migrator/src/migrations/mod.rs b/packages/migrator/src/migrations/mod.rs index 630c95662..42d349b7f 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 m20260414000000_snapshot_history; 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, + m20260414000000_snapshot_history::SnapshotHistory, ] } 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/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/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..cfe562820 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>( @@ -166,37 +161,28 @@ 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; +#[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); } - // 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); - } - }); + /// 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 new file mode 100644 index 000000000..4e41bfd29 --- /dev/null +++ b/packages/notebook-types/src/automerge_util.rs @@ -0,0 +1,427 @@ +//! 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common_test::{doc_from_json, doc_to_json}; + use automerge::{Automerge, ObjType, ReadDoc}; + use serde_json::json; + + #[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()); + } +} + +#[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 98b30f3ce..78b4b656e 100644 --- a/packages/notebook-types/src/lib.rs +++ b/packages/notebook-types/src/lib.rs @@ -6,9 +6,18 @@ use wasm_bindgen::prelude::*; mod v0; pub mod v1; +#[cfg(feature = "backend")] +pub mod automerge_json; + +#[cfg(feature = "backend")] +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)) diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index c2ef053f4..54de9d312 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -22,6 +22,7 @@ "@corvu/popover": "^0.2.0", "@corvu/resizable": "^0.2.5", "@corvu/tooltip": "^0.2.2", + "@github/relative-time-element": "^5.0.0", "@solid-primitives/active-element": "^2.1.3", "@solid-primitives/destructure": "^0.2.1", "katex": "^0.16.22", diff --git a/packages/ui-components/pnpm-lock.yaml b/packages/ui-components/pnpm-lock.yaml index af95e5e80..728992e35 100644 --- a/packages/ui-components/pnpm-lock.yaml +++ b/packages/ui-components/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@corvu/tooltip': specifier: ^0.2.2 version: 0.2.2(solid-js@1.9.10) + '@github/relative-time-element': + specifier: ^5.0.0 + version: 5.0.0 '@solid-primitives/active-element': specifier: ^2.1.3 version: 2.1.3(solid-js@1.9.10) @@ -438,6 +441,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@github/relative-time-element@5.0.0': + resolution: {integrity: sha512-L/2r0DNR/rMbmHWcsdmhtOiy2gESoGOhItNFD4zJ3nZfHl79Dx3N18Vfx/pYr2lruMOdk1cJZb4wEumm+Dxm1w==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2220,6 +2226,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@github/relative-time-element@5.0.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': 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..95a0ee9a4 --- /dev/null +++ b/packages/ui-components/src/history_navigator.stories.tsx @@ -0,0 +1,297 @@ +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: () => , + 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[]; undoTooltip?: string; redoTooltip?: string }) { + return ( +
+ {}} + onRedo={() => {}} + onSelect={() => {}} + undoTooltip={props.undoTooltip} + redoTooltip={props.redoTooltip} + /> +
+ ); +} + +/** Entries spanning minutes, hours, days, months, and previous years. */ +export const MixedTimeRanges: Story = { + render: () => ( + + ), +}; + +/** 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: () => { + 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 new file mode 100644 index 000000000..7307be1f9 --- /dev/null +++ b/packages/ui-components/src/history_navigator.tsx @@ -0,0 +1,214 @@ +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 { RelativeTime } from "./relative_time"; +import { createVirtualList } from "./virtual_list"; + +import styles from "./history_navigator.module.css"; + +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; + /** Tooltip for the undo button. Defaults to "Undo". */ + undoTooltip?: string; + /** Tooltip for the redo button. Defaults to "Redo". */ + redoTooltip?: string; +}; + +const ROW_HEIGHT = 44; + +/** Panel for navigating document snapshot history with undo/redo and a scrollable list. */ +export function HistoryNavigator(props: HistoryNavigatorProps) { + // 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 pending = optimisticId(); + const raw = props.items.map((item) => ({ + ...item, + minuteKey: Math.floor(item.createdAt / 60_000), + })); + + 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]; + if (!item) { + continue; + } + 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) => { + const isActive = pending != null ? item.id === pending : item.active; + return { + id: item.id, + active: isActive, + createdAt: item.createdAt, + 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 ( +
+
+ { + 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"} + > + + +
+
+
+
+ + {(item) => ( + + )} + +
+
+
+
+ ); +} diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 58c4b5a52..de93d87b7 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"; @@ -17,8 +18,10 @@ export * from "./katex_display"; export * from "./model_file_icon"; export * from "./name_input"; export * from "./panel"; +export * from "./relative_time"; 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/ui-components/src/relative_time.stories.tsx b/packages/ui-components/src/relative_time.stories.tsx new file mode 100644 index 000000000..df228ec58 --- /dev/null +++ b/packages/ui-components/src/relative_time.stories.tsx @@ -0,0 +1,55 @@ +import { For } from "solid-js"; +import type { Meta, StoryObj } from "storybook-solidjs-vite"; + +import { RelativeTime } from "./relative_time"; + +const meta = { + title: "Misc/RelativeTime", + component: RelativeTime, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const now = Date.now(); + +const examples: { label: string; timestamp: number }[] = [ + { label: "Just now", timestamp: now - 10_000 }, + { label: "5 minutes ago", timestamp: now - 5 * 60_000 }, + { label: "1 hour 30 min ago", timestamp: now - 90 * 60_000 }, + { label: "6 hours ago", timestamp: now - 6 * 3600_000 }, + { label: "Yesterday", timestamp: now - 24 * 3600_000 }, + { label: "4 days ago", timestamp: now - 4 * 24 * 3600_000 }, + { label: "2 weeks ago", timestamp: now - 14 * 24 * 3600_000 }, + { label: "6 months ago", timestamp: now - 180 * 24 * 3600_000 }, +]; + +export const Summary: Story = { + args: { timestamp: now - 5 * 60_000 }, + tags: ["!autodocs", "!dev"], +}; + +export const AllRanges: Story = { + render: () => ( + + + + + + + + + + {(ex) => ( + + + + + )} + + +
OffsetRendered
{ex.label} + +
+ ), +}; diff --git a/packages/ui-components/src/relative_time.tsx b/packages/ui-components/src/relative_time.tsx new file mode 100644 index 000000000..672c01ac2 --- /dev/null +++ b/packages/ui-components/src/relative_time.tsx @@ -0,0 +1,27 @@ +// oxlint-disable-next-line no-unassigned-import -- side-effect import registers the custom element +import "@github/relative-time-element"; + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + "relative-time": JSX.HTMLAttributes & { + datetime?: string; + format?: string; + "format-style"?: string; + tense?: string; + precision?: string; + threshold?: string; + prefix?: string; + "no-title"?: boolean; + lang?: string; + }; + } + } +} + +/** Display a timestamp as relative text, with the exact time as a tooltip. Auto-updates. */ +export function RelativeTime(props: { timestamp: number }) { + const datetime = () => new Date(props.timestamp).toISOString(); + + return ; +} 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