Tags: TheAlexPG/ArchFlow
Tags
feat: per-user undo/redo (#12) * feat(undo): migration for undo_entries table and user.undo_settings * fix(undo): JSONB cast on user.undo_settings default + index FK columns - backend/app/models/user.py: server_default uses sa.text("'{}'::jsonb") to avoid create_all() syntax error. - backend/alembic/versions/0246c9846364_*.py: add standalone indexes on diagram_id and draft_id so CASCADE deletes don't seq-scan at scale. * feat(undo): UndoEntry SQLAlchemy model * feat(undo): pydantic schemas * style(undo): use model_config dict to match house style * feat(undo): undo_service.record with coalescing and cap - record() coalesces within COALESCE_WINDOW_SECONDS (2s) on (user, diagram, draft, coalesce_key); merge keeps the FIRST inverse_payload (so undo restores the pre-burst state) but updates forward_summary, after_state, and updated_at. - New action clears any state='undone' redo entries for the context. - Per-context FIFO cap (PER_CONTEXT_CAP=100) evicts the oldest beyond the cap. - Service tests live in backend/tests/services/test_undo_service.py; added DB + user/workspace/diagram fixtures to backend/tests/conftest.py with a TRUNCATE-on-entry isolation pattern. - Drive-by: undo_entry model enums now use values_callable so DB values match the migration (lowercase) instead of Python enum names. * feat(undo): undo + redo with Figma redo-snapshot trick and skip-on-missing * feat(undo): history reader and retention sweeper * feat(undo): undo_to atomic multi-step Adds undo_to(), _undo_until(), _redo_until(), UndoToResult, and UndoEntryNotFound. undo_to walks the active or undone stack atomically until the target entry is itself applied, matching the Figma/Excalidraw history-popover UX ("click entry X = roll back to before X"). Stop condition uses strict less-than (top.seq < target_seq) so the target entry is included. All 12 service tests pass. * fix(undo): undo_to draft_id guard + reject SKIPPED targets * feat(undo): REST endpoints for undo, redo, history, undo-to Adds four endpoints under /diagrams/{diagram_id}/: POST /undo, POST /redo, GET /history, POST /undo-to/{entry_id} Auth guard: get_current_user (401 if unauthenticated) + inline workspace membership check mirroring diagrams.py (403 if non-member). No require_diagram_edit dep is used — it doesn't exist in this repo. Integration tests cover: 204 on empty stack (undo + redo), empty history, undo-to 404 for unknown entry, 401 for all four endpoints without auth, 403 for non-workspace-member, 409 seq mismatch with correct error code. 7/7 new tests pass; zero regressions in existing tests/api/. * fix(undo): return cursor seq (not target seq) on undo_to 409 * feat(undo): record undo entries from each canvas mutation site * fix(undo): include metadata in undo snapshots; hoist connection workspace lookup * feat(undo): emit user.undo / user.redo WS events Adds fire_and_forget_publish_user helper to manager.py (mirrors the existing diagram-scoped helper). Calls it from undo_endpoint, redo_endpoint, and undo_to_endpoint after db.commit(). The /ws/me socket already auto-subscribes to user:{id} so no WS handler changes needed. Verifies with a new test that monkeypatches the publisher and checks the event fires after a seeded undo. * feat(undo): draft hooks, undo_settings PATCH, sweeper job - Wire UndoEntry cleanup into discard_draft, apply_draft, and delete_draft so no orphaned undo entries remain when a draft is closed - Add UserUpdate schema and PATCH /auth/me endpoint to let users toggle undo_settings (PATCH-semantics, full replace) - Expose undo_settings field on UserResponse so the round-trip works - Create backend/app/jobs/undo_sweeper.py and db-sweep-undo Makefile target for daily hard-deletion of entries older than RETENTION_DAYS - Tests: draft-cleanup service test, PATCH /me API test (25 green) * feat(undo): hand-written React Query hooks for undo endpoints * feat(undo): zustand undo-store * feat(canvas): generic useDebouncedMutation hook; adopt in EdgeSidebar * feat(undo): controller hotkeys and state-aware undo hooks * feat(canvas): history popover with click-to-undo-to-point * feat(canvas): undo/redo toolbar buttons with history trigger * feat(canvas): mount undo toolbar/controller and thread context through mutation sites * test(undo): multi-user scenarios — LWW, delete-undo, concurrent races Also fixes two related bugs uncovered while writing the tests: - activity_service._snapshot now uses mapper column_attrs (Python attr names) instead of __table__.columns (SQL names) so metadata_ is read correctly and MetaData() is never serialized into JSONB. - restore_service._materialise accepts both Python attr names and SQL column names from snapshots so old and new snapshots round-trip correctly. * docs: per-user undo smoke checklist * docs: README — per-user undo feature mention * fix(undo): drop unused type imports; stabilise empty-stack reference tsc -b surfaced two unused type imports in use-undo.ts (UndoActionResponse, UndoToResponse). Removed them. The Zustand selector `getStackInfo` returned a fresh emptyStack() object on every call when no entry existed for the context, causing useSyncExternalStore to detect a change every render and trigger 'Maximum update depth exceeded'. Switched to a frozen singleton EMPTY_STACK so the selector returns a stable reference. The factory emptyStack() is still used inside set operations where fresh objects are correct. * fix(undo): record pre-flip endpoints; broadcast diagram.refetch on undo Two backend issues from the Codex review: 1. flip_connection recorded inverse_payload={}, so undo of a flip ran _apply_update with no fields and was a silent no-op. Now captures the pre-flip source_id/target_id/source_handle/target_handle in inverse_payload.before so the generic apply path swaps them back. Locked in with a new service test. 2. The undo apply path mutates entities directly via setattr / db.delete / db.add and skips the per-route entity events (object.updated, etc.) the frontend uses to refresh React Query caches. After every commit, the route now also fires a diagram-scoped 'diagram.refetch' event so the actor's canvas (and other clients on the same diagram) drop their entity caches and re-read. * fix(undo): flip context as query params; wire diagram.refetch + relocate user.* handlers Three frontend issues: 1. useFlipConnection sent from_diagram_id/from_draft_id in the POST body, but the backend reads them as query params (the flip route has no request body schema). Body-supplied values were silently dropped, so flips never produced an undo entry from the UI. 2. Added a diagram.refetch handler to useDiagramSocket that invalidates objects, connections, diagram-objects, comments, and undo-history caches. Pairs with the backend broadcast — the actor's canvas now reflects undo/redo immediately, and multi-user collab on undo also propagates. 3. The user.undo / user.redo / user.undo_to handlers were placed in useDiagramSocket, but those events fire to the user:{id} room, not diagram:{id} — the handlers were dead code. Moved them to useUserSocket which already subscribes to /ws/me. Cache-refresh responsibility moved to the new diagram.refetch handler. * fix(undo): record draft creates; burst-undo multi-target operations Two backend issues from Codex review (P2): 1. create_object and create_connection gated undo recording on 'draft_id is None', so creating an object/connection inside a draft never produced an undo entry — pressing Cmd+Z right after adding it to a draft was a no-op. Both gates removed; from_draft_id is already threaded into undo_service.record so the entry lands on the correct draft-scoped stack and is cleaned up on draft discard/apply. 2. Multi-object drags fire per-object position mutations in tight succession (each in its own coalesce_key like 'diagram_object:<id>:position'), so the user got N separate undo entries for one logical operation and a single Cmd+Z reverted only the last-moved object. Added a burst loop in undo() / redo() that, after applying the top entry, walks peer entries created within BURST_WINDOW_SECONDS (150ms) that share the same target_type and coalesce-key kind ('position', 'create', etc.) and applies them too in the same transaction. The window is tight enough that intentional sequential edits (typing renames are >200ms apart) don't false-merge, and the target_type + kind filters prevent a position drag from sweeping in an immediately-adjacent name edit. undo_to passes burst_floor_seq / burst_ceiling_seq through to undo()/redo() so the burst can't punch past the user's chosen target seq. Three new service tests cover the burst path (multi-drag undo, multi-drag redo, kind-boundary respected). * fix(undo): derive undo/redo counts from history into the store The toolbar's Undo and Redo buttons only enable when undoCount or redoCount is > 0, but the only path that wrote those numbers to the Zustand store was useUndoMutation/useRedoMutation on success — the useDiagramHistory hook copied the entries list across but left the counts at their default 0. Result: opening the history popover (or just first-load) showed entries but kept the toolbar disabled. Now derive cursorSeq, undoCount, redoCount from query.data and write them via setStackInfo alongside the entries. Counts come from a single linear scan of entries (active = undo, undone = redo).