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