From 4e2b2873868221fc36c0dc4a1c339e695b349796 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 6 Jun 2022 16:31:38 -0400 Subject: [PATCH] AdminAction and AdminActionRepo This allows me to enqueue a description of a change to a stream and then run it. And then later find / list them and undo any of them. The goal here is to make these safe to run and safe to reverse so the user can just run things with confidence knowing that undo's always got their back. This allows me to avoid confirmation boxes on everything and careful scrutinizing of each command before it's run just in case... --- lib/admin_action.rb | 139 +++++++++++++++++++++++++++++++++++++++ lib/admin_action_repo.rb | 86 ++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 lib/admin_action.rb create mode 100644 lib/admin_action_repo.rb diff --git a/lib/admin_action.rb b/lib/admin_action.rb new file mode 100644 index 0000000000000000000000000000000000000000..576140d8cbe8b8c5bc400631e53b3b12c388db5f --- /dev/null +++ b/lib/admin_action.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "delegate" + +class AdminAction + class NoOp + def to_s + "NoOp" + end + end + + module Direction + class InvalidDirection < StandardError; end + + def self.for(direction) + { + forward: Forward, + reverse: Reverse, + reforward: Reforward + }.fetch(direction.to_sym) { raise InvalidDirection } + end + + class Forward < SimpleDelegator + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def perform + check_forward.then { forward }.then { |x| self.class.new(x) } + end + + def to_h + super.merge(direction: :forward) + end + + def undo + Reverse.new(__getobj__.with(parent_id: id)) + end + end + + class Reverse < SimpleDelegator + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def perform + check_reverse.then { reverse }.then { |x| self.class.new(x) } + end + + def to_s + "UNDO(#{parent_id}) #{super}" + end + + def to_h + super.merge(direction: :reverse) + end + + def undo + Reforward.new(__getobj__) + end + end + + class Reforward < Forward + def with(**kwargs) + self.class.new(__getobj__.with(**kwargs)) + end + + def to_s + "REDO(#{parent_id}) #{super}" + end + + def to_h + super.merge(direction: :reforward) + end + + def undo + Reverse.new(__getobj__) + end + end + end + + def self.for(**kwargs) + Direction::Forward.new(new(**kwargs)) + end + + def initialize(**kwargs) + @attributes = kwargs + end + + def with(**kwargs) + self.class.new(@attributes.merge(kwargs)) + end + + def id + @attributes[:id] + end + + def parent_id + @attributes[:parent_id] + end + + def actor_id + @attributes[:actor_id] + end + + def check_forward + EMPromise.resolve(nil) + end + + def forward + EMPromise.resolve(self) + end + + def check_reverse + EMPromise.resolve(nil) + end + + def reverse + EMPromise.resolve(self) + end + + def to_h + @attributes.merge({ + class: self.class.to_s.delete_prefix("AdminAction::") + }.compact) + end + + module Isomorphic + def check_reverse + to_reverse.check_forward + end + + def reverse + # We don't want it to return the reversed one + # We want it to return itself but with the reverse state + to_reverse.forward.then { self } + end + end +end diff --git a/lib/admin_action_repo.rb b/lib/admin_action_repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..4dc59a3f466f3d9f0fa771b3c0eb5275a8541d30 --- /dev/null +++ b/lib/admin_action_repo.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class AdminActionRepo + class NotFound < StandardError; end + + def initialize(redis: REDIS) + @redis = redis + end + + def build(klass:, direction:, **kwargs) + dir = AdminAction::Direction.for(direction) + dir.new(AdminAction.const_get(klass).new(**kwargs)) + end + + # I'm using hash subset test for pred + # So if you give me any keys I'll find only things where those keys are + # present and set to that value + def find(limit, max="+", **pred) + return EMPromise.resolve([]) unless limit.positive? + + xrevrange( + "admin_actions", max: max, min: "-", count: limit + ).then { |new_max, results| + next [] if results.empty? + + selected = results.select { |_id, values| pred < values } + .map { |id, values| build(id: id, **rename_class(values)) } + + find(limit - selected.length, "(#{new_max}", **pred) + .then { |r| selected + r } + } + end + + def create(action) + push_to_redis(**action.to_h).then { |id| + action.with(id: id) + } + end + +protected + + def rename_class(hash) + hash.transform_keys { |k| k == :class ? :klass : k } + end + + # Turn value into a hash, paper over redis version issue, return earliest ID + def xrevrange(stream, min:, max:, count:) + min = next_id(min[1..-1]) if min.start_with?("(") + max = previous_id(max[1..-1]) if max.start_with?("(") + + @redis.xrevrange(stream, max, min, "COUNT", count).then { |result| + next ["+", []] if result.empty? + + [ + result.last.first, # Reverse order, so this is the lowest ID + result.map { |id, values| [id, Hash[*values].transform_keys(&:to_sym)] } + ] + } + end + + # Versions of REDIS after 6.2 can just do "(#{current_id}" to make an + # exclusive version + def previous_id(current_id) + time, seq = current_id.split("-") + if seq == "0" + "#{time.to_i - 1}-18446744073709551615" + else + "#{time}-#{seq.to_i - 1}" + end + end + + # Versions of REDIS after 6.2 can just do "(#{current_id}" to make an + # exclusive version + def next_id(current_id) + time, seq = current_id.split("-") + if seq == "18446744073709551615" + "#{time.to_i + 1}-0" + else + "#{time}-#{seq.to_i + 1}" + end + end + + def push_to_redis(**kwargs) + @redis.xadd("admin_actions", "*", *kwargs.flatten) + end +end