Skip to content

Tags: TheAlexPG/ArchFlow

Tags

v1.0.0

Toggle v1.0.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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).