rollout-active_record-adapter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/lib/generators/rollout/active_record/install_generator.rb +27 -0
- data/lib/generators/rollout/active_record/templates/create_rollout_tables.rb.tt +41 -0
- data/lib/rollout/active_record/codec.rb +57 -0
- data/lib/rollout/active_record/feature_cache.rb +94 -0
- data/lib/rollout/active_record/migration.rb +242 -0
- data/lib/rollout/active_record/schema.rb +64 -0
- data/lib/rollout/adapters/active_record.rb +386 -0
- data/lib/rollout-active_record-adapter.rb +3 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ecefa7e74cc8b39f614ae2e2cb1a33e7072aa13ded266d394052356c41139492
|
|
4
|
+
data.tar.gz: 1e2cccffc216f98f7a10507fc90d9b9f27612135e878919d1296ace3f35e859b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: abf00af86c57f1340d03ef90d33c4225b6f82a183aee526c35c5f6f6e4ed479597579fe7e5c74ab06e793ab65d2ca00d234c30125b26c5eedef1707ff28682f5
|
|
7
|
+
data.tar.gz: 5c597704919dfdd503bfdcbd9a28175cc94ab49454d49045ead229e18315c6f48493a4f66e8d27511fbcd320c801a418b8caf9b0aec3f9369310b49af272785c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2010-InfinityAndBeyond BitLove, Inc.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
class Rollout
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
class_option :database, type: :string, desc: "Database key to generate the migration for"
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(dirname)
|
|
16
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template(
|
|
21
|
+
"create_rollout_tables.rb.tt",
|
|
22
|
+
File.join(db_migrate_path, "create_rollout_tables.rb"),
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRolloutTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
name_options = { null: false }
|
|
6
|
+
name_options[:collation] = "utf8mb4_bin" if connection.adapter_name.match?(/mysql/i)
|
|
7
|
+
payload_options = { null: false }
|
|
8
|
+
payload_options[:limit] = 16_777_215 if connection.adapter_name.match?(/mysql/i)
|
|
9
|
+
|
|
10
|
+
create_table :rollout_features do |t|
|
|
11
|
+
t.string :name, **name_options
|
|
12
|
+
t.float :percentage, limit: 53, null: false, default: 0.0
|
|
13
|
+
t.text :users, **payload_options
|
|
14
|
+
t.text :groups, **payload_options
|
|
15
|
+
t.text :data, **payload_options
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :rollout_features, :name, unique: true
|
|
20
|
+
|
|
21
|
+
create_table :rollout_events do |t|
|
|
22
|
+
t.string :feature_name, **name_options
|
|
23
|
+
t.string :event_name, null: false
|
|
24
|
+
t.text :data, **payload_options
|
|
25
|
+
t.text :context, **payload_options
|
|
26
|
+
t.boolean :feature_visible, null: false, default: true
|
|
27
|
+
t.boolean :global_visible, null: false, default: true
|
|
28
|
+
t.datetime :occurred_at, null: false, precision: 6
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :rollout_events,
|
|
32
|
+
[:feature_name, :feature_visible, :occurred_at],
|
|
33
|
+
name: "index_rollout_events_for_feature_history"
|
|
34
|
+
add_index :rollout_events,
|
|
35
|
+
[:global_visible, :occurred_at],
|
|
36
|
+
name: "index_rollout_events_for_global_history"
|
|
37
|
+
add_index :rollout_events,
|
|
38
|
+
[:feature_visible, :global_visible],
|
|
39
|
+
name: "index_rollout_events_for_hidden_cleanup"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rollout/feature_state'
|
|
5
|
+
require 'rollout/logging'
|
|
6
|
+
|
|
7
|
+
class Rollout
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
class Codec
|
|
10
|
+
def self.dump(value)
|
|
11
|
+
JSON.generate(value)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.load_array(payload)
|
|
15
|
+
JSON.parse(payload)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.load_hash(payload)
|
|
19
|
+
JSON.parse(payload)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.load_event_hash(payload)
|
|
23
|
+
JSON.parse(payload, symbolize_names: true)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.feature_state(name, record)
|
|
27
|
+
if record.nil?
|
|
28
|
+
return FeatureState.new(
|
|
29
|
+
name: name,
|
|
30
|
+
percentage: 0.0,
|
|
31
|
+
users: [],
|
|
32
|
+
groups: [],
|
|
33
|
+
data: {},
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
FeatureState.new(
|
|
38
|
+
name: name,
|
|
39
|
+
percentage: record.percentage,
|
|
40
|
+
users: load_array(record.users),
|
|
41
|
+
groups: load_array(record.groups),
|
|
42
|
+
data: load_hash(record.data),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.event(record)
|
|
47
|
+
Logging::Event.new(
|
|
48
|
+
feature: record.feature_name,
|
|
49
|
+
name: record.event_name,
|
|
50
|
+
data: load_event_hash(record.data),
|
|
51
|
+
context: load_event_hash(record.context),
|
|
52
|
+
created_at: record.occurred_at,
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Rollout
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
class FeatureCache
|
|
6
|
+
DEFAULT_MAX_SIZE = 4096
|
|
7
|
+
|
|
8
|
+
def initialize(ttl_seconds:, clock: nil, max_size: DEFAULT_MAX_SIZE)
|
|
9
|
+
unless ttl_seconds.is_a?(Integer) && ttl_seconds > 0
|
|
10
|
+
raise ArgumentError, "cache_ttl_seconds must be an Integer > 0"
|
|
11
|
+
end
|
|
12
|
+
unless max_size.is_a?(Integer) && max_size > 0
|
|
13
|
+
raise ArgumentError, "max_size must be an Integer > 0"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@ttl_seconds = ttl_seconds
|
|
17
|
+
@max_size = max_size
|
|
18
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
19
|
+
@entries = {}
|
|
20
|
+
@generation = 0
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def generation
|
|
25
|
+
@mutex.synchronize { @generation }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read(name, context: nil)
|
|
29
|
+
key = entry_key(name, context)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
entry = @entries[key]
|
|
32
|
+
return nil unless entry
|
|
33
|
+
if entry[:expires_at] <= now
|
|
34
|
+
@entries.delete(key)
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
entry[:state].deep_clone
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fill(generation, states, context: nil)
|
|
43
|
+
clones = states.map { |state| [state.name.to_s, state.deep_clone] }
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
return if generation != @generation
|
|
46
|
+
|
|
47
|
+
clones.each do |name, clone|
|
|
48
|
+
store_locked(name, clone, context)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delete(*names, context: nil)
|
|
54
|
+
keys = names.map { |name| entry_key(name, context) }
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@generation += 1
|
|
57
|
+
keys.each { |key| @entries.delete(key) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def clear
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@generation += 1
|
|
64
|
+
@entries.clear
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def store_locked(name, clone, context)
|
|
71
|
+
key = entry_key(name, context)
|
|
72
|
+
@entries.delete(key)
|
|
73
|
+
if @entries.size >= @max_size
|
|
74
|
+
prune_expired_locked
|
|
75
|
+
@entries.shift while @entries.size >= @max_size
|
|
76
|
+
end
|
|
77
|
+
@entries[key] = { state: clone, expires_at: now + @ttl_seconds }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def prune_expired_locked
|
|
81
|
+
t = now
|
|
82
|
+
@entries.delete_if { |_key, entry| entry[:expires_at] <= t }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entry_key(name, context)
|
|
86
|
+
[context, name.to_s]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def now
|
|
90
|
+
@clock.call
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Rollout
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
class Migration
|
|
6
|
+
class Difference
|
|
7
|
+
attr_reader :name, :kind, :field, :source, :destination
|
|
8
|
+
|
|
9
|
+
def initialize(name:, kind:, field: nil, source: nil, destination: nil)
|
|
10
|
+
@name = name
|
|
11
|
+
@kind = kind
|
|
12
|
+
@field = field
|
|
13
|
+
@source = source
|
|
14
|
+
@destination = destination
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ==(other)
|
|
18
|
+
other.is_a?(self.class) &&
|
|
19
|
+
name == other.name &&
|
|
20
|
+
kind == other.kind &&
|
|
21
|
+
field == other.field &&
|
|
22
|
+
source == other.source &&
|
|
23
|
+
destination == other.destination
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Result
|
|
28
|
+
attr_reader :status, :feature_count, :history_count, :missing_names, :unregistered_names, :differences
|
|
29
|
+
|
|
30
|
+
def initialize(
|
|
31
|
+
status:,
|
|
32
|
+
feature_count: 0,
|
|
33
|
+
history_count: 0,
|
|
34
|
+
missing_names: [],
|
|
35
|
+
unregistered_names: [],
|
|
36
|
+
differences: []
|
|
37
|
+
)
|
|
38
|
+
@status = status
|
|
39
|
+
@feature_count = feature_count
|
|
40
|
+
@history_count = history_count
|
|
41
|
+
@missing_names = missing_names
|
|
42
|
+
@unregistered_names = unregistered_names
|
|
43
|
+
@differences = differences
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def success?
|
|
47
|
+
status == :ok || status == :ready
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summary
|
|
51
|
+
case status
|
|
52
|
+
when :ready
|
|
53
|
+
ready_summary("Ready to import")
|
|
54
|
+
when :ok
|
|
55
|
+
ready_summary("Imported")
|
|
56
|
+
when :source_invalid
|
|
57
|
+
"Source invalid: missing=#{missing_names.inspect} unregistered=#{unregistered_names.inspect}"
|
|
58
|
+
when :destination_conflict
|
|
59
|
+
"Destination already has rollout data"
|
|
60
|
+
when :verification_failed
|
|
61
|
+
verification_failed_summary
|
|
62
|
+
else
|
|
63
|
+
"Migration failed (#{status})"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def ready_summary(prefix)
|
|
70
|
+
features = "#{feature_count} feature#{'s' unless feature_count == 1}"
|
|
71
|
+
if history_count > 0
|
|
72
|
+
events = "#{history_count} history event#{'s' unless history_count == 1}"
|
|
73
|
+
"#{prefix} #{features} and #{events}"
|
|
74
|
+
else
|
|
75
|
+
"#{prefix} #{features}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def verification_failed_summary
|
|
80
|
+
message = "Imported data did not match the source"
|
|
81
|
+
difference = differences.first
|
|
82
|
+
return message unless difference
|
|
83
|
+
|
|
84
|
+
details = [difference.kind, difference.name, difference.field].compact.join(" ")
|
|
85
|
+
"#{message}: #{details}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(source:, destination:, include_history: false)
|
|
90
|
+
@source = source
|
|
91
|
+
@destination = destination
|
|
92
|
+
@include_history = include_history
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def dry_run
|
|
96
|
+
execute(write: false)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run
|
|
100
|
+
execute(write: true)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def execute(write:)
|
|
106
|
+
export = @source.export_features(include_history: @include_history)
|
|
107
|
+
history = @include_history ? Array(export.history) : []
|
|
108
|
+
counts = { feature_count: export.states.size, history_count: history.size }
|
|
109
|
+
|
|
110
|
+
unless export.valid?
|
|
111
|
+
return Result.new(
|
|
112
|
+
status: :source_invalid,
|
|
113
|
+
missing_names: export.missing_names,
|
|
114
|
+
unregistered_names: export.unregistered_names,
|
|
115
|
+
**counts,
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if @destination.occupied?
|
|
120
|
+
return Result.new(status: :destination_conflict, **counts)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
return Result.new(status: :ready, **counts) unless write
|
|
124
|
+
|
|
125
|
+
failed = nil
|
|
126
|
+
begin
|
|
127
|
+
@destination.import_features(export.states, history: history) do
|
|
128
|
+
differences = compare_states(export.states, imported_states) +
|
|
129
|
+
compare_history(history)
|
|
130
|
+
next if differences.empty?
|
|
131
|
+
|
|
132
|
+
failed = Result.new(status: :verification_failed, differences: differences, **counts)
|
|
133
|
+
raise ::ActiveRecord::Rollback
|
|
134
|
+
end
|
|
135
|
+
rescue ::Rollout::Adapters::ActiveRecord::DestinationNotEmpty
|
|
136
|
+
return Result.new(status: :destination_conflict, **counts)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
failed || Result.new(status: :ok, **counts)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def imported_states
|
|
143
|
+
@destination.fetch_features(@destination.feature_names)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def compare_states(source_states, destination_states)
|
|
147
|
+
source_by_name = source_states.to_h { |state| [state.name, state] }
|
|
148
|
+
destination_by_name = destination_states.to_h { |state| [state.name, state] }
|
|
149
|
+
names = (source_by_name.keys | destination_by_name.keys).sort
|
|
150
|
+
differences = []
|
|
151
|
+
|
|
152
|
+
names.each do |name|
|
|
153
|
+
source = source_by_name[name]
|
|
154
|
+
destination = destination_by_name[name]
|
|
155
|
+
|
|
156
|
+
if source.nil?
|
|
157
|
+
differences << Difference.new(name: name, kind: :extra)
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if destination.nil?
|
|
162
|
+
differences << Difference.new(name: name, kind: :missing)
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
next if source == destination
|
|
167
|
+
|
|
168
|
+
%i[percentage users groups data].each do |field|
|
|
169
|
+
source_value = source.public_send(field)
|
|
170
|
+
destination_value = destination.public_send(field)
|
|
171
|
+
next if source_value == destination_value
|
|
172
|
+
|
|
173
|
+
differences << Difference.new(
|
|
174
|
+
name: name,
|
|
175
|
+
kind: :changed,
|
|
176
|
+
field: field,
|
|
177
|
+
source: source_value,
|
|
178
|
+
destination: destination_value,
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
differences
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def compare_history(source_entries)
|
|
187
|
+
source_by_feature = Hash.new { |hash, name| hash[name] = [] }
|
|
188
|
+
source_global = []
|
|
189
|
+
|
|
190
|
+
source_entries.each do |entry|
|
|
191
|
+
event = entry.event
|
|
192
|
+
source_by_feature[event.feature.to_s] << event if entry.feature_visible
|
|
193
|
+
source_global << event if entry.global_visible
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
names = (source_by_feature.keys | history_feature_names).sort
|
|
197
|
+
differences = []
|
|
198
|
+
|
|
199
|
+
names.each do |name|
|
|
200
|
+
differences.concat(
|
|
201
|
+
event_differences(
|
|
202
|
+
name,
|
|
203
|
+
source_by_feature[name],
|
|
204
|
+
@destination.feature_events(name),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
differences.concat(event_differences("_global_", source_global, @destination.global_events))
|
|
209
|
+
differences
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def history_feature_names
|
|
213
|
+
@destination.global_events.map { |event| event.feature.to_s } |
|
|
214
|
+
@destination.feature_names.map(&:to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def event_differences(name, source_events, destination_events)
|
|
218
|
+
source_signatures = source_events.map { |event| event_signature(event) }
|
|
219
|
+
destination_signatures = destination_events.map { |event| event_signature(event) }
|
|
220
|
+
return [] if source_signatures == destination_signatures
|
|
221
|
+
|
|
222
|
+
[Difference.new(
|
|
223
|
+
name: name,
|
|
224
|
+
kind: :history,
|
|
225
|
+
field: :events,
|
|
226
|
+
source: source_signatures,
|
|
227
|
+
destination: destination_signatures,
|
|
228
|
+
)]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def event_signature(event)
|
|
232
|
+
{
|
|
233
|
+
feature: event.feature.to_s,
|
|
234
|
+
name: event.name.to_s,
|
|
235
|
+
data: event.data,
|
|
236
|
+
context: event.context,
|
|
237
|
+
timestamp: (event.created_at.to_r * 1_000_000).round,
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Rollout
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module Schema
|
|
6
|
+
def self.create(connection, features_table: "rollout_features", events_table: "rollout_events")
|
|
7
|
+
name_options = name_column_options(connection)
|
|
8
|
+
payload_options = payload_column_options(connection)
|
|
9
|
+
|
|
10
|
+
connection.create_table(features_table) do |table|
|
|
11
|
+
table.string :name, **name_options
|
|
12
|
+
table.float :percentage, limit: 53, null: false, default: 0.0
|
|
13
|
+
table.text :users, **payload_options
|
|
14
|
+
table.text :groups, **payload_options
|
|
15
|
+
table.text :data, **payload_options
|
|
16
|
+
table.timestamps
|
|
17
|
+
end
|
|
18
|
+
connection.add_index(features_table, :name, unique: true)
|
|
19
|
+
|
|
20
|
+
connection.create_table(events_table) do |table|
|
|
21
|
+
table.string :feature_name, **name_options
|
|
22
|
+
table.string :event_name, null: false
|
|
23
|
+
table.text :data, **payload_options
|
|
24
|
+
table.text :context, **payload_options
|
|
25
|
+
table.boolean :feature_visible, null: false, default: true
|
|
26
|
+
table.boolean :global_visible, null: false, default: true
|
|
27
|
+
table.datetime :occurred_at, null: false, precision: 6
|
|
28
|
+
end
|
|
29
|
+
connection.add_index(
|
|
30
|
+
events_table,
|
|
31
|
+
[:feature_name, :feature_visible, :occurred_at],
|
|
32
|
+
name: "index_#{events_table}_for_feature_history",
|
|
33
|
+
)
|
|
34
|
+
connection.add_index(
|
|
35
|
+
events_table,
|
|
36
|
+
[:global_visible, :occurred_at],
|
|
37
|
+
name: "index_#{events_table}_for_global_history",
|
|
38
|
+
)
|
|
39
|
+
connection.add_index(
|
|
40
|
+
events_table,
|
|
41
|
+
[:feature_visible, :global_visible],
|
|
42
|
+
name: "index_#{events_table}_for_hidden_cleanup",
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.drop(connection, features_table: "rollout_features", events_table: "rollout_events")
|
|
47
|
+
connection.drop_table(events_table, if_exists: true)
|
|
48
|
+
connection.drop_table(features_table, if_exists: true)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.name_column_options(connection)
|
|
52
|
+
options = { null: false }
|
|
53
|
+
options[:collation] = "utf8mb4_bin" if connection.adapter_name.match?(/mysql/i)
|
|
54
|
+
options
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.payload_column_options(connection)
|
|
58
|
+
options = { null: false }
|
|
59
|
+
options[:limit] = 16_777_215 if connection.adapter_name.match?(/mysql/i)
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'rollout'
|
|
5
|
+
require 'rollout/active_record/schema'
|
|
6
|
+
require 'rollout/active_record/codec'
|
|
7
|
+
require 'rollout/active_record/migration'
|
|
8
|
+
require 'rollout/active_record/feature_cache'
|
|
9
|
+
|
|
10
|
+
class Rollout
|
|
11
|
+
module Adapters
|
|
12
|
+
class ActiveRecord
|
|
13
|
+
class DestinationNotEmpty < ArgumentError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
base_record_class: ::ActiveRecord::Base,
|
|
18
|
+
features_table_name: "rollout_features",
|
|
19
|
+
events_table_name: "rollout_events",
|
|
20
|
+
cache_ttl_seconds: nil
|
|
21
|
+
)
|
|
22
|
+
@base_record_class = base_record_class
|
|
23
|
+
@features_table_name = features_table_name
|
|
24
|
+
@events_table_name = events_table_name
|
|
25
|
+
@feature_record = build_record_class(@features_table_name)
|
|
26
|
+
@event_record = build_record_class(@events_table_name)
|
|
27
|
+
@feature_cache = cache_ttl_seconds.nil? ? nil : ::Rollout::ActiveRecord::FeatureCache.new(ttl_seconds: cache_ttl_seconds)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch_feature(name)
|
|
31
|
+
if use_feature_cache?
|
|
32
|
+
context = cache_context
|
|
33
|
+
cached = @feature_cache.read(name, context: context)
|
|
34
|
+
return cached if cached
|
|
35
|
+
|
|
36
|
+
generation = @feature_cache.generation
|
|
37
|
+
state = load_feature(name)
|
|
38
|
+
@feature_cache.fill(generation, [state], context: context)
|
|
39
|
+
return state
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
load_feature(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch_features(names)
|
|
46
|
+
return [] if names.empty?
|
|
47
|
+
return load_features(names) unless use_feature_cache?
|
|
48
|
+
|
|
49
|
+
context = cache_context
|
|
50
|
+
hits, misses = partition_cached_features(names, context)
|
|
51
|
+
fill_feature_cache(hits, misses, context) unless misses.empty?
|
|
52
|
+
names.map { |name| hits[name.to_s].deep_clone }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def feature_names
|
|
56
|
+
@feature_record.order(:id).pluck(:name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def feature_exists?(name)
|
|
60
|
+
@feature_record.exists?(name: name.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def save_feature(state)
|
|
64
|
+
@feature_record.transaction do
|
|
65
|
+
persist_state(locked_feature(state.name), state)
|
|
66
|
+
invalidate_features_after_commit(state.name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def delete_feature(name)
|
|
71
|
+
@feature_record.where(name: name.to_s).delete_all
|
|
72
|
+
invalidate_features_after_commit(name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def clear_features
|
|
76
|
+
@feature_record.delete_all
|
|
77
|
+
invalidate_all_features_after_commit
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def occupied?
|
|
81
|
+
@feature_record.uncached do
|
|
82
|
+
@feature_record.exists? || @event_record.exists?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def import_features(states, history: [])
|
|
87
|
+
raise ArgumentError, "states must be an Array" unless states.is_a?(Array)
|
|
88
|
+
raise ArgumentError, "history must be an Array" unless history.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
states.each do |state|
|
|
91
|
+
next if state.is_a?(::Rollout::FeatureState)
|
|
92
|
+
|
|
93
|
+
raise ArgumentError, "states must contain FeatureState objects"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@feature_record.transaction(requires_new: true) do
|
|
97
|
+
if occupied?
|
|
98
|
+
raise DestinationNotEmpty, "destination already has rollout data"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
states.each { |state| persist_state(nil, state) }
|
|
102
|
+
history.each { |entry| insert_imported_event(entry) }
|
|
103
|
+
yield if block_given?
|
|
104
|
+
invalidate_all_features_after_commit
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mutate_feature(name)
|
|
109
|
+
rollback_error = nil
|
|
110
|
+
mutation = nil
|
|
111
|
+
|
|
112
|
+
@feature_record.transaction(requires_new: true) do
|
|
113
|
+
record = locked_feature(name)
|
|
114
|
+
begin
|
|
115
|
+
mutation = yield ::Rollout::ActiveRecord::Codec.feature_state(name, record)
|
|
116
|
+
rescue ::ActiveRecord::Rollback => error
|
|
117
|
+
rollback_error = error
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
persist_mutation(record, mutation)
|
|
121
|
+
invalidate_features_after_commit(name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
raise rollback_error if rollback_error
|
|
125
|
+
|
|
126
|
+
mutation
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def feature_events(name, limit: nil)
|
|
130
|
+
events_from(
|
|
131
|
+
@event_record.where(feature_name: name.to_s, feature_visible: true),
|
|
132
|
+
limit: limit,
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def global_events(limit: nil)
|
|
137
|
+
events_from(
|
|
138
|
+
@event_record.where(global_visible: true),
|
|
139
|
+
limit: limit,
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def feature_updated_at(name)
|
|
144
|
+
@event_record
|
|
145
|
+
.where(feature_name: name.to_s, feature_visible: true)
|
|
146
|
+
.order(occurred_at: :desc, id: :desc)
|
|
147
|
+
.limit(1)
|
|
148
|
+
.pick(:occurred_at)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def delete_feature_events(name)
|
|
152
|
+
@feature_record.transaction do
|
|
153
|
+
@event_record.where(feature_name: name.to_s, feature_visible: true)
|
|
154
|
+
.update_all(feature_visible: false)
|
|
155
|
+
delete_hidden_events
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def partition_cached_features(names, context)
|
|
162
|
+
hits = {}
|
|
163
|
+
misses = []
|
|
164
|
+
names.map(&:to_s).uniq.each do |name|
|
|
165
|
+
cached = @feature_cache.read(name, context: context)
|
|
166
|
+
if cached
|
|
167
|
+
hits[name] = cached
|
|
168
|
+
else
|
|
169
|
+
misses << name
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
[hits, misses]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def fill_feature_cache(hits, misses, context)
|
|
176
|
+
generation = @feature_cache.generation
|
|
177
|
+
load_features(misses).each do |state|
|
|
178
|
+
hits[state.name] = state
|
|
179
|
+
end
|
|
180
|
+
@feature_cache.fill(generation, misses.map { |name| hits[name] }, context: context)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def load_feature(name)
|
|
184
|
+
record = with_query_cache_bypass { find_feature(name) }
|
|
185
|
+
::Rollout::ActiveRecord::Codec.feature_state(name, record)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def load_features(names)
|
|
189
|
+
return [] if names.empty?
|
|
190
|
+
|
|
191
|
+
unique_names = names.map(&:to_s).uniq
|
|
192
|
+
records = with_query_cache_bypass do
|
|
193
|
+
@feature_record.where(name: unique_names).index_by(&:name)
|
|
194
|
+
end
|
|
195
|
+
names.map { |name| ::Rollout::ActiveRecord::Codec.feature_state(name, records[name.to_s]) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def with_query_cache_bypass
|
|
199
|
+
return yield unless @feature_cache
|
|
200
|
+
|
|
201
|
+
@feature_record.uncached { yield }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def use_feature_cache?
|
|
205
|
+
@feature_cache && !@feature_record.connection.transaction_open?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def cache_context
|
|
209
|
+
@feature_record.connection_pool.object_id
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def invalidate_features_after_commit(*names)
|
|
213
|
+
return unless @feature_cache
|
|
214
|
+
|
|
215
|
+
context = cache_context
|
|
216
|
+
after_outer_commit { @feature_cache.delete(*names, context: context) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def invalidate_all_features_after_commit
|
|
220
|
+
return unless @feature_cache
|
|
221
|
+
|
|
222
|
+
after_outer_commit { @feature_cache.clear }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def after_outer_commit(&block)
|
|
226
|
+
txn = outermost_open_transaction
|
|
227
|
+
if txn.nil?
|
|
228
|
+
block.call
|
|
229
|
+
elsif txn.respond_to?(:after_commit)
|
|
230
|
+
txn.after_commit(&block)
|
|
231
|
+
else
|
|
232
|
+
txn.add_record(AfterCommitCallback.new(&block))
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def outermost_open_transaction
|
|
237
|
+
connection = @feature_record.connection
|
|
238
|
+
return nil unless connection.transaction_open?
|
|
239
|
+
|
|
240
|
+
stack = connection.transaction_manager.instance_variable_get(:@stack)
|
|
241
|
+
stack&.first
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def build_record_class(table_name)
|
|
245
|
+
Class.new(@base_record_class) do
|
|
246
|
+
self.table_name = table_name
|
|
247
|
+
self.inheritance_column = :_type_disabled
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def find_feature(name)
|
|
252
|
+
@feature_record.find_by(name: name.to_s)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def locked_feature(name)
|
|
256
|
+
@feature_record.uncached do
|
|
257
|
+
@feature_record.lock.find_by(name: name.to_s)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def persist_mutation(record, mutation)
|
|
262
|
+
persist_state(record, mutation.fetch(:state))
|
|
263
|
+
event = mutation[:event]
|
|
264
|
+
return unless event
|
|
265
|
+
|
|
266
|
+
history_length = mutation.fetch(:history_length)
|
|
267
|
+
validate_history_length!(history_length)
|
|
268
|
+
insert_event(event, global: mutation[:global])
|
|
269
|
+
prune_feature_events(event.feature, history_length)
|
|
270
|
+
prune_global_events(history_length) if mutation[:global]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def persist_state(record, state)
|
|
274
|
+
attributes = {
|
|
275
|
+
percentage: state.percentage,
|
|
276
|
+
users: ::Rollout::ActiveRecord::Codec.dump(state.users),
|
|
277
|
+
groups: ::Rollout::ActiveRecord::Codec.dump(state.groups),
|
|
278
|
+
data: ::Rollout::ActiveRecord::Codec.dump(state.data),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if record
|
|
282
|
+
record.update!(attributes)
|
|
283
|
+
else
|
|
284
|
+
@feature_record.create!(attributes.merge(name: state.name.to_s))
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def insert_event(event, global:)
|
|
289
|
+
@event_record.create!(
|
|
290
|
+
feature_name: event.feature.to_s,
|
|
291
|
+
event_name: event.name.to_s,
|
|
292
|
+
data: ::Rollout::ActiveRecord::Codec.dump(event.data),
|
|
293
|
+
context: ::Rollout::ActiveRecord::Codec.dump(event.context),
|
|
294
|
+
feature_visible: true,
|
|
295
|
+
global_visible: global ? true : false,
|
|
296
|
+
occurred_at: event.created_at,
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def insert_imported_event(entry)
|
|
301
|
+
event = entry.event
|
|
302
|
+
@event_record.create!(
|
|
303
|
+
feature_name: event.feature.to_s,
|
|
304
|
+
event_name: event.name.to_s,
|
|
305
|
+
data: ::Rollout::ActiveRecord::Codec.dump(event.data),
|
|
306
|
+
context: ::Rollout::ActiveRecord::Codec.dump(event.context),
|
|
307
|
+
feature_visible: !!entry.feature_visible,
|
|
308
|
+
global_visible: !!entry.global_visible,
|
|
309
|
+
occurred_at: event.created_at,
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def prune_feature_events(name, history_length)
|
|
314
|
+
hide_excess(
|
|
315
|
+
@event_record.where(feature_name: name.to_s, feature_visible: true),
|
|
316
|
+
:feature_visible,
|
|
317
|
+
history_length,
|
|
318
|
+
)
|
|
319
|
+
delete_hidden_events
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def prune_global_events(history_length)
|
|
323
|
+
hide_excess(
|
|
324
|
+
@event_record.where(global_visible: true),
|
|
325
|
+
:global_visible,
|
|
326
|
+
history_length,
|
|
327
|
+
)
|
|
328
|
+
delete_hidden_events
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def hide_excess(scope, column, history_length)
|
|
332
|
+
if history_length == 0
|
|
333
|
+
scope.update_all(column => false)
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
keep_ids = scope.order(occurred_at: :desc, id: :desc).limit(history_length).pluck(:id)
|
|
338
|
+
return if keep_ids.empty?
|
|
339
|
+
|
|
340
|
+
scope.where.not(id: keep_ids).update_all(column => false)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def delete_hidden_events
|
|
344
|
+
@event_record.where(feature_visible: false, global_visible: false).delete_all
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def events_from(scope, limit:)
|
|
348
|
+
unless limit.nil?
|
|
349
|
+
raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer)
|
|
350
|
+
raise ArgumentError, "limit must be >= 0" if limit < 0
|
|
351
|
+
return [] if limit.zero?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
records = scope.order(occurred_at: :desc, id: :desc)
|
|
355
|
+
records = records.limit(limit) unless limit.nil?
|
|
356
|
+
records.to_a.reverse.map { |record| ::Rollout::ActiveRecord::Codec.event(record) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def validate_history_length!(history_length)
|
|
360
|
+
unless history_length.is_a?(Integer) && history_length >= 0
|
|
361
|
+
raise ArgumentError, "history_length must be an Integer >= 0"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
class AfterCommitCallback
|
|
366
|
+
def initialize(&block)
|
|
367
|
+
@block = block
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def committed!(*)
|
|
371
|
+
@block.call
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def before_committed!
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def rolledback!(*)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def trigger_transactional_callbacks?
|
|
381
|
+
true
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rollout-active_record-adapter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- FetLife
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rollout
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '4'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '4'
|
|
52
|
+
description: Active Record adapter for the rollout gem.
|
|
53
|
+
email:
|
|
54
|
+
- dev@fetlife.com
|
|
55
|
+
executables: []
|
|
56
|
+
extensions: []
|
|
57
|
+
extra_rdoc_files: []
|
|
58
|
+
files:
|
|
59
|
+
- LICENSE
|
|
60
|
+
- lib/generators/rollout/active_record/install_generator.rb
|
|
61
|
+
- lib/generators/rollout/active_record/templates/create_rollout_tables.rb.tt
|
|
62
|
+
- lib/rollout-active_record-adapter.rb
|
|
63
|
+
- lib/rollout/active_record/codec.rb
|
|
64
|
+
- lib/rollout/active_record/feature_cache.rb
|
|
65
|
+
- lib/rollout/active_record/migration.rb
|
|
66
|
+
- lib/rollout/active_record/schema.rb
|
|
67
|
+
- lib/rollout/adapters/active_record.rb
|
|
68
|
+
homepage: https://github.com/FetLife/rollout
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata: {}
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '2.7'
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 3.6.9
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Active Record adapter for the rollout gem.
|
|
89
|
+
test_files: []
|