Fix undo to restore clipped polygons via event sourcing - #28
Merged
Conversation
Undo was broken after any paint that triggered clipping: the eraser removed the new polygon but the originals were gone permanently since they were destroyed at paint time and never recorded. Approach: treat each paint as an immutable event in a new paint_events table and make rated_areas a derived cache rebuilt by replaying active events. Undo flips the latest event to 'undone' and rebuilds; redo reactivates it incrementally. Undo/redo state now persists across reloads and is authoritative on the server. - Add migration 006: paint_events table (user_id, seq, geometry, value, status) - Extract apply_paint() helper from the paint handler so rebuild can call it per-event - Add rebuild_rated_areas_for_user() that wipes and replays all active events - paint endpoint: appends event, clears redo stack, applies to cache - New POST /api/ratings/undo and /api/ratings/redo endpoints - All responses include can_undo/can_redo; GET /api/ratings includes them too - Frontend: drop ephemeral undoStack/redoStack, drive button state from server
Backfill paint_events from existing rated_areas during migration so that users with pre-migration data don't lose their areas on the first undo after upgrading. Existing areas are non-overlapping by invariant, so replaying them in any order produces the same derived state. Integration tests added: - undo/redo require auth - undo is a no-op when history is empty - simple paint → undo removes area; redo restores it - new paint clears the redo stack - undo restores a partially clipped polygon to its original full shape - undo restores a fully covered (deleted) polygon - multi-step undo chain empties the overlay - GET /api/ratings includes can_undo/can_redo flags
Simulates the production migration scenario directly: paints two areas, wipes paint_events to replicate pre-migration state, re-runs the backfill SQL from migration 006, then verifies that a subsequent paint + undo leaves the pre-migration areas intact rather than destroying them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What broke
Undo was silently losing data after any paint that triggered polygon clipping. The eraser removed the newly-painted polygon, but the original polygons it had clipped were gone forever — they were destroyed at paint time with no record kept.
What changed
Replaced the ephemeral browser-side undo stack with a server-backed event log.
Data model: New
paint_eventstable is the source of truth (user_id, seq, geometry, value, status).rated_areasbecomes a derived cache rebuilt by replaying active events. Undo marks the latest active event as undone and rebuilds the cache from scratch; redo reactivates the earliest undone event and applies it incrementally.Backend (
ratings.rs):apply_paint()helper (the clipping logic) sorebuild_rated_areas_for_user()can call it per-eventpaintendpoint: appends event, clears redo stack, applies to cachePOST /api/ratings/undoandPOST /api/ratings/redoendpointscan_undo/can_redo;GET /api/ratingsincludes them too for page-load stateFrontend:
undoStack/redoStackarrays — history lives on the serverWhy this approach
The alternative (capture originals in the paint response and send them back through a restore endpoint) requires threading fragile state through a UNION CTE across two
RETURNINGclauses. Event sourcing makes the invariant structural: originals are never lost because paint never destroys — it appends. Undo/redo also now persist across reloads and devices.Testing