Skip to content

Undo system: trigger-based undo log with multi-level Ctrl+Z #90

Description

@ApocDev

Real multi-level undo for planning edits, built on SQLite's canonical trigger-based undo-log pattern (https://www.sqlite.org/undoredo.html). This supersedes the undo-toast half of #83 and complements snapshots (#85): undo unwinds your last few edits, snapshots restore how a block looked at a chosen point.

Design

  • Trigger-based inverse log. AFTER INSERT/UPDATE/DELETE triggers on the planning tables write the inverse statement into an undo_log table — a DELETE logs the restoring INSERT with the old values, an UPDATE logs an UPDATE with the old values. Pure SQL, works through better-sqlite3/Drizzle, lives in a normal migration.
  • Table-scoped, structurally. Triggers go only on user-planning tables (blocks, recipe rows, goals, splits/modules, tasks, notes, folders). Imported Factorio-dump tables, live-state/bridge tables, app config, snapshots, and the undo log itself never get triggers — the high-volume system writes are excluded by construction, not by per-call discipline. This also keeps data sync and imports at full speed.
  • Action grouping. An undo step is a user action, not a row change. Every mutating server function runs through a shared mutation wrapper that opens an action (one action id), so "apply this plan" (dozens of rows) pops as a single undo. better-sqlite3 is synchronous and each request runs to completion, so actions can't interleave.
  • Tracking is opt-out, not opt-in. The wrapper records an undo action by default; the rare system-driven writes to planning tables (snapshot restore internals, migration backfills, dump-import scaffolding) explicitly pass { undo: false }. Rationale: almost every write to a planning table is a deliberate user edit, and the failure modes are asymmetric — a forgotten opt-out just puts one odd entry on the stack, while under opt-in a forgotten opt-in silently makes an action un-undoable. The triggers' WHEN clause checks the current-action marker, so any write that somehow bypasses the wrapper is simply untracked (fail-soft) rather than logged as an orphan.
  • Linear undo only. Undo pops strictly from the top of the stack — no undoing action N−3 while later actions touched the same rows. Executing an undo must not re-log itself as a new action (either log it as a redo entry or mark it undo: false); redo support is optional and can come later.
  • Retention. Keep the last N actions (e.g. 50) per project, trimmed on write. The log is scoped per project db like everything else.
  • UI. Ctrl+Z / Cmd+Z via the global hotkey layer from Command palette: Ctrl+K / / search-everything #78, a toolbar/undo-menu affordance showing what the next undo will revert ("Undo: remove 3 recipes from Iron Pulp"), and a brief toast after each undo. Assistant-applied changes (draft/revise apply) are single actions and therefore undoable — a nice safety valve for propose-then-apply.

Interplay

Part of the polish epic #35.

Scope: plan content vs view state

Undo covers changes to what the plan is — anything the solver or the stored plan consumes (goals, rates, recipe sets, splits, modules, enabled flags, keep-in-stock/refill windows, notes). It deliberately excludes view state and preferences — transport-visibility toggles, display units (/s vs /min), compact-number formatting, collapsed groups, tabs, sidebar layout. Undoing a hidden view toggle is disorienting and buries real edits.

Mechanically this falls out of the table scoping: preferences live in localStorage or a prefs/settings table (no triggers), plan content lives on planning rows (triggers). Display-ish columns that happen to live on planning rows (e.g. a per-goal rate-window unit) ride along in undo rather than being excluded with column-comparing WHEN clauses — such edits are rare and undoing one is harmless. Corollary for future features: state that shouldn't be undoable belongs in prefs storage, not on plan rows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: webWeb UI (React/TanStack/vite-plus)enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions