Module: OpenLoam::Undo
- Defined in:
- lib/open_loam/undo.rb
Overview
Undo/redo built ON the audit trail (L-704). Every change already records a OpenLoam::AuditRecord with its changeset; undoing one applies the inverse and writes a NEW audit record — so the inverse is itself undoable, and "redo" is just undoing that undo entry. No separate command log to keep in sync.
THE STACK: a field revert is refused unless the audit is the record's LATEST update/undo — undoing an old change out from under newer ones would silently clobber them. Undo therefore walks the history back one entry at a time.
BOUNDARIES (each a deliberate skip, not a gap):
* encrypted fields — the audit stores "[encrypted]", never the old value,
so there is nothing to revert to;
* the workflow column — a direct write is blocked by the transition gate
(OpenLoam::Workflow), so state is undone by performing the reverse TRANSITION,
not here;
* fields the actor's role may not write (when a policy is supplied).
Defined Under Namespace
Classes: NotUndoableError
Constant Summary collapse
- IGNORE =
%w[id lock_version created_at updated_at].freeze
Class Method Summary collapse
- .ensure_current_tenant!(audit) ⇒ Object
-
.ensure_latest!(audit) ⇒ Object
The audit must be the record's most recent update/undo — else undoing it would overwrite newer edits.
- .guard_soft_delete(record) ⇒ Object
-
.load_record(audit) ⇒ Object
Look the record up through its OWN default scope (tenant holds), lifting only the soft-delete filter so a soft-deleted row is still reachable to restore.
-
.revert_fields(record, audit, policy) ⇒ Object
--- internals ---.
- .soft_deletable?(audit) ⇒ Boolean
-
.undo(audit, policy: nil) ⇒ Object
Apply the inverse of one audit record.
- .undo_create(record) ⇒ Object
-
.undoable?(audit) ⇒ Boolean
Coarse, action-level check the admin view consults to decide whether to offer an Undo button.
Class Method Details
.ensure_current_tenant!(audit) ⇒ Object
122 123 124 |
# File 'lib/open_loam/undo.rb', line 122 def ensure_current_tenant!(audit) raise NotUndoableError, "not in this tenant" unless audit.tenant_id == OpenLoam.tenant!.id end |
.ensure_latest!(audit) ⇒ Object
The audit must be the record's most recent update/undo — else undoing it would overwrite newer edits. Tenant-scoped via AuditRecord's default scope.
94 95 96 97 98 99 100 101 |
# File 'lib/open_loam/undo.rb', line 94 def ensure_latest!(audit) latest = OpenLoam::AuditRecord .where(auditable_type: audit.auditable_type, auditable_id: audit.auditable_id, action: %w[update undo]) .order(:id).last return if latest.nil? || latest.id == audit.id raise NotUndoableError, "a newer change exists — undo that first" end |
.guard_soft_delete(record) ⇒ Object
85 86 87 88 89 90 |
# File 'lib/open_loam/undo.rb', line 85 def guard_soft_delete(record) unless record.respond_to?(:soft_delete!) && record.respond_to?(:restore!) raise NotUndoableError, "#{record.class.name} is not soft-deletable, so this change can't be undone" end record end |
.load_record(audit) ⇒ Object
Look the record up through its OWN default scope (tenant holds), lifting only the soft-delete filter so a soft-deleted row is still reachable to restore. Never AuditRecord#auditable, which is unscoped.
106 107 108 109 110 111 112 113 114 115 |
# File 'lib/open_loam/undo.rb', line 106 def load_record(audit) klass = audit.auditable_type.safe_constantize raise NotUndoableError, "unknown type #{audit.auditable_type.inspect}" unless klass.is_a?(Class) && klass < OpenLoam::TenantRecord scope = klass.respond_to?(:with_deleted) ? klass.with_deleted : klass.all record = scope.find_by(id: audit.auditable_id) raise NotUndoableError, "the record no longer exists" unless record record end |
.revert_fields(record, audit, policy) ⇒ Object
--- internals ---
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/open_loam/undo.rb', line 54 def revert_fields(record, audit, policy) ensure_latest!(audit) workflow_column = record.class.respond_to?(:open_loam_workflow) ? record.class.open_loam_workflow&.column.to_s : nil updates = {} (audit.changeset || {}).each do |field, values| next unless values.is_a?(Array) # skip "[encrypted]" (a String) next if IGNORE.include?(field) next if field == workflow_column # undo state via the reverse transition, not a direct write next unless record.class.column_names.include?(field) next if policy && !policy.writable?(field) updates[field] = values.first # the "before" value end raise NotUndoableError, "nothing revertable in this change" if updates.empty? # Relabel this save's audit as "undo" so the history reads legibly (and the # entry is itself undoable = redo). open_loam_audit_as is the same hook # SoftDeletable uses for soft_delete/restore. if record.respond_to?(:open_loam_audit_as, true) record.send(:open_loam_audit_as, "undo") { record.update!(updates) } else record.update!(updates) end end |
.soft_deletable?(audit) ⇒ Boolean
117 118 119 120 |
# File 'lib/open_loam/undo.rb', line 117 def soft_deletable?(audit) klass = audit.auditable_type.safe_constantize klass.is_a?(Class) && klass.instance_methods.include?(:soft_delete!) end |
.undo(audit, policy: nil) ⇒ Object
Apply the inverse of one audit record. Returns the affected record.
policy: (optional) restricts a field revert to policy-writable fields.
38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/open_loam/undo.rb', line 38 def undo(audit, policy: nil) ensure_current_tenant!(audit) record = load_record(audit) case audit.action when "update", "undo" then revert_fields(record, audit, policy) when "create" then undo_create(record) when "soft_delete" then guard_soft_delete(record).restore! when "restore" then guard_soft_delete(record).soft_delete! else raise NotUndoableError, "#{audit.action.inspect} cannot be undone" end record end |
.undo_create(record) ⇒ Object
81 82 83 |
# File 'lib/open_loam/undo.rb', line 81 def undo_create(record) guard_soft_delete(record).soft_delete! end |
.undoable?(audit) ⇒ Boolean
Coarse, action-level check the admin view consults to decide whether to
offer an Undo button. The fine checks (something revertable, still latest)
happen in undo and surface as NotUndoableError.
28 29 30 31 32 33 34 |
# File 'lib/open_loam/undo.rb', line 28 def undoable?(audit) case audit.action when "update", "undo" then true when "create", "soft_delete", "restore" then soft_deletable?(audit) else false end end |