AdminAction and AdminActionRepo

Christopher Vollick created

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...

Change summary

lib/admin_action.rb      | 139 ++++++++++++++++++++++++++++++++++++++++++
lib/admin_action_repo.rb |  86 +++++++++++++++++++++++++
2 files changed, 225 insertions(+)

Detailed changes

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

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