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