tape_deck 0.1.0.beta1
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/.devcontainer/compose.yml +33 -0
- data/.devcontainer/devcontainer.json +13 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/DEVELOPMENT.md +86 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +85 -0
- data/lib/tape_deck/configuration.rb +113 -0
- data/lib/tape_deck/errors.rb +54 -0
- data/lib/tape_deck/filter.rb +72 -0
- data/lib/tape_deck/patterns.rb +21 -0
- data/lib/tape_deck/recorder.rb +270 -0
- data/lib/tape_deck/session.rb +100 -0
- data/lib/tape_deck/tape.rb +152 -0
- data/lib/tape_deck/version.rb +6 -0
- data/lib/tape_deck.rb +295 -0
- data/sig/tape_deck.rbs +4 -0
- metadata +95 -0
data/lib/tape_deck.rb
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "date"
|
|
6
|
+
require "time"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "yaml"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
|
|
11
|
+
require_relative "tape_deck/version"
|
|
12
|
+
require_relative "tape_deck/errors"
|
|
13
|
+
require_relative "tape_deck/configuration"
|
|
14
|
+
require_relative "tape_deck/tape"
|
|
15
|
+
require_relative "tape_deck/patterns"
|
|
16
|
+
require_relative "tape_deck/filter"
|
|
17
|
+
require_relative "tape_deck/session"
|
|
18
|
+
require_relative "tape_deck/recorder"
|
|
19
|
+
|
|
20
|
+
# Records what a block of code changes in several MySQL databases, and checks later runs against that recording.
|
|
21
|
+
# API, naming and flow inspired by the VCR gem.
|
|
22
|
+
#
|
|
23
|
+
# == Tapes
|
|
24
|
+
#
|
|
25
|
+
# A tape is one recording: the row-level diff that one action caused across all configured databases, stored as a YAML
|
|
26
|
+
# file in {Configuration#tape_library_dir}. It plays the role of VCR's cassette. Unlike a cassette it is never played
|
|
27
|
+
# back. The action always runs for real, and the tape is what its result is checked against.
|
|
28
|
+
#
|
|
29
|
+
# A tape cannot safely be played back, i.e. re-applied to a database. It holds the net effect of the action, not the
|
|
30
|
+
# statements that caused it or their order, so INSERT/UPDATE/DELETE statements generated from it can go wrong in many
|
|
31
|
+
# ways: foreign key constraints, generated columns, triggers.
|
|
32
|
+
#
|
|
33
|
+
# Next to the diff, a tape stores what was recorded: the exclude rules, the watched databases, and the tables and
|
|
34
|
+
# columns the rules left out of each.
|
|
35
|
+
#
|
|
36
|
+
# recorded:
|
|
37
|
+
# exclude_tables: [shop.sessions]
|
|
38
|
+
# exclude_columns: ["*.created_at"]
|
|
39
|
+
# databases:
|
|
40
|
+
# shop:
|
|
41
|
+
# excluded: [orders.created_at, sessions]
|
|
42
|
+
# billing:
|
|
43
|
+
# diff:
|
|
44
|
+
# shop:
|
|
45
|
+
# orders:
|
|
46
|
+
# inserted: [{ "id" => 42, "status" => "new", ... }]
|
|
47
|
+
# updated: [{ "key" => { "id" => 7 }, "changes" => { "status" => ["new", "paid"] } }]
|
|
48
|
+
# deleted: [...]
|
|
49
|
+
#
|
|
50
|
+
# == Lifecycle
|
|
51
|
+
#
|
|
52
|
+
# 1. *Insert* ({TapeDeck.insert_tape}): a snapshot of all watched databases is taken before the action runs. A tape to
|
|
53
|
+
# be verified is read now, so a missing, broken or incompatible tape fails before the action runs.
|
|
54
|
+
# 2. The action runs.
|
|
55
|
+
# 3. *Eject* ({TapeDeck.eject_tape}): a second snapshot is taken and the diff between the two is captured. Then one of:
|
|
56
|
+
# * *record*: the diff is written to the tape file, if the file is missing or the record mode is +:overwrite+
|
|
57
|
+
# * *verify*: the diff is compared with the tape file, and {UnexpectedDiffError} is raised if they differ. See
|
|
58
|
+
# "Excluding and ignoring" for narrowing what is compared.
|
|
59
|
+
# * *discard*: nothing is written or compared, because the action itself failed
|
|
60
|
+
#
|
|
61
|
+
# {TapeDeck.use_tape} does all three steps around a block. See {Tape::RECORD_MODES} for the record modes.
|
|
62
|
+
#
|
|
63
|
+
# == Nested tapes
|
|
64
|
+
#
|
|
65
|
+
# Tapes can be nested. Each tape takes its own snapshots, so an outer tape also contains every change made while an
|
|
66
|
+
# inner tape was inserted. Tapes are ejected innermost first: {TapeDeck.eject_tape} always ejects the most recently
|
|
67
|
+
# inserted one. If an inner tape fails to verify inside an outer {TapeDeck.use_tape} block, the error propagates and
|
|
68
|
+
# the outer tape is discarded. With {TapeDeck.insert_tape} and {TapeDeck.eject_tape}, discarding is up to you.
|
|
69
|
+
#
|
|
70
|
+
# TapeDeck.use_tape("checkout") do # records the changes of both requests
|
|
71
|
+
# client.post("/cart", ...)
|
|
72
|
+
# TapeDeck.use_tape("checkout/payment") do # records only the changes of this request
|
|
73
|
+
# client.post("/pay", ...)
|
|
74
|
+
# end
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# == Selecting databases
|
|
78
|
+
#
|
|
79
|
+
# When recording, a tape watches all configured databases by default. Pass +databases:+ to watch only some of them.
|
|
80
|
+
# The others are neither read nor compared, so changes there go unnoticed.
|
|
81
|
+
#
|
|
82
|
+
# When verifying, a tape watches the databases it was recorded with by default. +databases:+ may select a subset of
|
|
83
|
+
# those, the rest of the tape is then not compared. Selecting a database the tape has no recording of raises
|
|
84
|
+
# {IncompatibleTapeError}.
|
|
85
|
+
#
|
|
86
|
+
# TapeDeck.use_tape("checkout", databases: %w[shop billing]) { client.post("/checkout", ...) }
|
|
87
|
+
#
|
|
88
|
+
# == Patterns
|
|
89
|
+
#
|
|
90
|
+
# Tables are named +"<database>.<table>"+ and columns +"<database>.<table>.<column>"+, using the keys of
|
|
91
|
+
# {Configuration#databases}. All rules below are glob patterns on these names (<tt>File.fnmatch</tt>, where +*+ also
|
|
92
|
+
# matches dots), so +"*.created_at"+ matches that column in every table of every database, and +"shop.*"+ matches
|
|
93
|
+
# everything in +shop+.
|
|
94
|
+
#
|
|
95
|
+
# == Excluding and ignoring
|
|
96
|
+
#
|
|
97
|
+
# There are two ways to leave data out, and they act at different moments.
|
|
98
|
+
#
|
|
99
|
+
# *Exclude* means never recorded. {Configuration#exclude_tables} and {Configuration#exclude_columns} apply to every
|
|
100
|
+
# tape and are meant for noise no test cares about, like generated timestamps. Excluded data never reaches a tape. The
|
|
101
|
+
# tape stores what was left out, so the rules can change later: data that is excluded now, but is on the tape, is not
|
|
102
|
+
# compared. Data the tape left out, but that is no longer excluded, is missing from the tape, so verifying raises
|
|
103
|
+
# {IncompatibleTapeError} and the tape needs re-recording.
|
|
104
|
+
#
|
|
105
|
+
# The stored rules also reveal schema changes. When verifying, they are applied to the current schema again. If they
|
|
106
|
+
# leave out other tables or columns than when recording, a table or column matching them was added or removed.
|
|
107
|
+
# {Configuration#on_schema_change} decides whether that raises {IncompatibleTapeError}, warns, or is ignored.
|
|
108
|
+
#
|
|
109
|
+
# *Ignore* means recorded, but not compared. A tape always holds everything that is not excluded. When verifying, one
|
|
110
|
+
# use of a tape can narrow what is compared with +only:+ and +ignore:+. Both take one pattern or a list, and a pattern
|
|
111
|
+
# may name a whole database (+"billing.*"+), a table (+"shop.orders"+) or a column (+"shop.orders.status"+). With
|
|
112
|
+
# +only:+, everything else is ignored. +ignore:+ wins over +only:+. Neither has any effect while recording, so these
|
|
113
|
+
# rules can change without re-recording.
|
|
114
|
+
#
|
|
115
|
+
# TapeDeck.use_tape("checkout", only: "shop.orders.status") { client.post("/checkout", ...) }
|
|
116
|
+
# TapeDeck.use_tape("checkout", ignore: ["shop.orders.note", "billing.*"]) { client.post("/checkout", ...) }
|
|
117
|
+
#
|
|
118
|
+
# @example Configure
|
|
119
|
+
# TapeDeck.configure do |c|
|
|
120
|
+
# c.tape_library_dir = "spec/db_tapes"
|
|
121
|
+
# c.databases = { "shop" => SHOP_DB, "billing" => BILLING_DB }
|
|
122
|
+
# c.default_record_mode = :once
|
|
123
|
+
# c.exclude_tables "shop.sessions", "*.schema_migrations"
|
|
124
|
+
# c.exclude_columns "*.created_at", "*.updated_at", "shop.users.last_seen_at"
|
|
125
|
+
# c.on_schema_change = :warn
|
|
126
|
+
# c.configure_rspec_metadata!
|
|
127
|
+
# end
|
|
128
|
+
#
|
|
129
|
+
# @example Record or verify a tape
|
|
130
|
+
# TapeDeck.use_tape("checkout/creates_order") { client.post("/checkout", ...) }
|
|
131
|
+
#
|
|
132
|
+
# @example Watch only some databases
|
|
133
|
+
# TapeDeck.use_tape("checkout/creates_order", databases: %w[shop billing]) { client.post("/checkout", ...) }
|
|
134
|
+
# TapeDeck.use_tape("signup/sends_welcome_mail", databases: "mailer") { client.post("/signup", ...) }
|
|
135
|
+
#
|
|
136
|
+
# @example Compare only part of what is on the tape
|
|
137
|
+
# TapeDeck.use_tape("checkout/creates_order", only: "shop.orders.status") { client.post("/checkout", ...) }
|
|
138
|
+
# TapeDeck.use_tape("checkout/creates_order", ignore: "billing.invoices.number") { client.post("/checkout", ...) }
|
|
139
|
+
#
|
|
140
|
+
# @example RSpec, after +configure_rspec_metadata!+
|
|
141
|
+
# RSpec.describe "Checkout" do
|
|
142
|
+
# # tape "Checkout/creates an order", all databases
|
|
143
|
+
# it "creates an order", :tape_deck do
|
|
144
|
+
# client.post("/checkout", ...)
|
|
145
|
+
# end
|
|
146
|
+
#
|
|
147
|
+
# # only the shop database, custom tape name
|
|
148
|
+
# it "reserves stock", tape_deck: { databases: %w[shop], tape_name: "checkout/stock" } do
|
|
149
|
+
# client.post("/checkout", ...)
|
|
150
|
+
# end
|
|
151
|
+
#
|
|
152
|
+
# # compare only the order status
|
|
153
|
+
# it "marks the order as paid", tape_deck: { only: "shop.orders.status" } do
|
|
154
|
+
# client.post("/checkout", ...)
|
|
155
|
+
# end
|
|
156
|
+
#
|
|
157
|
+
# # re-record this tape on every run
|
|
158
|
+
# it "creates an invoice", tape_deck: { record: :overwrite } do
|
|
159
|
+
# client.post("/checkout", ...)
|
|
160
|
+
# end
|
|
161
|
+
# end
|
|
162
|
+
module TapeDeck
|
|
163
|
+
class << self
|
|
164
|
+
# @return [Configuration]
|
|
165
|
+
def configuration = @configuration ||= Configuration.new
|
|
166
|
+
|
|
167
|
+
# @yieldparam config [Configuration]
|
|
168
|
+
# @return [void]
|
|
169
|
+
def configure
|
|
170
|
+
yield configuration
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Records or verifies the database changes made by the block. If the block raises, the tape is neither recorded
|
|
174
|
+
# nor verified and the error propagates.
|
|
175
|
+
#
|
|
176
|
+
# @param name [String] tape name, e.g. +"checkout/creates_order"+
|
|
177
|
+
# @param options [Hash] keyword options, see {.insert_tape}
|
|
178
|
+
# @yield the action whose database changes are recorded
|
|
179
|
+
# @return [Object] the block's return value
|
|
180
|
+
# @raise [UnexpectedDiffError] if the changes do not match the tape
|
|
181
|
+
# @raise [TapeLoadingError] if the tape must be verified but does not exist or cannot be read
|
|
182
|
+
def use_tape(name, **options)
|
|
183
|
+
insert_tape(name, **options)
|
|
184
|
+
completed = false
|
|
185
|
+
begin
|
|
186
|
+
result = yield
|
|
187
|
+
completed = true
|
|
188
|
+
result
|
|
189
|
+
ensure
|
|
190
|
+
eject_tape(discard: !completed)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Starts a tape without a block: creates the tape and takes the "before" snapshot. Pair with {.eject_tape}. Tapes
|
|
195
|
+
# may be nested, see "Nested tapes" in {TapeDeck}.
|
|
196
|
+
#
|
|
197
|
+
# Like +File.open+ without a block, this leaves the rest to you. {.use_tape} and the RSpec tag eject the tape in
|
|
198
|
+
# any case, and discard it if the action fails. Here you have to:
|
|
199
|
+
# * eject the tape with +discard: true+ if the action fails, otherwise the failed action is recorded or verified
|
|
200
|
+
# * eject outer tapes yourself if ejecting an inner one raises. The inner one is ejected anyway, but the outer
|
|
201
|
+
# ones stay inserted, so usually eject them with +discard: true+.
|
|
202
|
+
#
|
|
203
|
+
# @param name [String] tape name, e.g. +"checkout/creates_order"+
|
|
204
|
+
# @param databases [Array<String>, String, nil] names of the configured databases to watch, all of them if nil.
|
|
205
|
+
# See "Selecting databases" in {TapeDeck}.
|
|
206
|
+
# @param only [Array<String>, String, nil] patterns for what to compare when verifying, everything if nil. See
|
|
207
|
+
# "Excluding and ignoring" in {TapeDeck}.
|
|
208
|
+
# @param ignore [Array<String>, String, nil] patterns for what not to compare when verifying
|
|
209
|
+
# @param options [Hash] keyword options, see {Tape#initialize}
|
|
210
|
+
# @return [Tape]
|
|
211
|
+
# @raise [ArgumentError] for a database name that is not configured
|
|
212
|
+
# @raise (see Tape#initialize)
|
|
213
|
+
# @example Insert and eject manually, the way {.use_tape} does
|
|
214
|
+
# TapeDeck.insert_tape("checkout/creates_order")
|
|
215
|
+
# begin
|
|
216
|
+
# client.post("/checkout", ...)
|
|
217
|
+
# rescue
|
|
218
|
+
# TapeDeck.eject_tape(discard: true)
|
|
219
|
+
# raise
|
|
220
|
+
# end
|
|
221
|
+
# TapeDeck.eject_tape
|
|
222
|
+
def insert_tape(name, databases: nil, only: nil, ignore: nil, **options)
|
|
223
|
+
tape = Tape.new(name, **options)
|
|
224
|
+
recorder = Recorder.new(
|
|
225
|
+
select_databases(tape_databases(tape, databases)),
|
|
226
|
+
exclude_tables: configuration.excluded_tables,
|
|
227
|
+
exclude_columns: configuration.excluded_columns
|
|
228
|
+
)
|
|
229
|
+
filter = Filter.new(only: only, ignore: ignore)
|
|
230
|
+
sessions.push(Session.new(tape, recorder, filter, on_schema_change: configuration.on_schema_change))
|
|
231
|
+
tape
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Ejects the most recently inserted tape, i.e. the innermost one when tapes are nested: captures the diff, then
|
|
235
|
+
# records it to the tape or verifies it against the tape. The tape is ejected even if this raises, see
|
|
236
|
+
# {.insert_tape} for what that means for outer tapes.
|
|
237
|
+
#
|
|
238
|
+
# @param discard [Boolean] neither record nor verify, e.g. because the block failed
|
|
239
|
+
# @return [Tape, nil] the ejected tape, or nil if none was inserted
|
|
240
|
+
# @raise [UnexpectedDiffError] if verifying and the diff does not match the tape
|
|
241
|
+
# @raise [IncompatibleTapeError] if verifying and the tape lacks data that is no longer excluded, or the schema
|
|
242
|
+
# changed, see {Configuration#on_schema_change}
|
|
243
|
+
# @raise [TapeLoadingError] if verifying and the tape cannot be read, see {Tape#read}
|
|
244
|
+
def eject_tape(discard: false)
|
|
245
|
+
session = sessions.pop
|
|
246
|
+
return unless session
|
|
247
|
+
|
|
248
|
+
session.finish(discard: discard)
|
|
249
|
+
session.tape
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @return [Tape, nil] the most recently inserted tape that has not been ejected yet
|
|
253
|
+
def current_tape = sessions.last&.tape
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def sessions = @sessions ||= []
|
|
258
|
+
|
|
259
|
+
# When verifying, the tape must hold a recording of every watched database. Without a selection, these are the
|
|
260
|
+
# databases the tape was recorded with. Reads the tape, so a broken one fails before the action runs.
|
|
261
|
+
#
|
|
262
|
+
# @param tape [Tape]
|
|
263
|
+
# @param names [Array<String>, String, nil] the selection passed to {.insert_tape}
|
|
264
|
+
# @return [Array<String>, nil] names of the databases to watch, all configured ones if nil
|
|
265
|
+
# @raise [IncompatibleTapeError] if the tape has no recording of a selected database
|
|
266
|
+
def tape_databases(tape, names)
|
|
267
|
+
return names if tape.recording?
|
|
268
|
+
|
|
269
|
+
recorded = tape.read.fetch("recorded").fetch("databases").keys
|
|
270
|
+
names = Array(names || recorded).map(&:to_s)
|
|
271
|
+
missing = names - recorded
|
|
272
|
+
unless missing.empty?
|
|
273
|
+
raise IncompatibleTapeError,
|
|
274
|
+
"tape #{tape.file} has no recording of #{missing.inspect}, it was recorded with #{recorded.inspect}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
names
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @param names [Array<String>, String, nil]
|
|
281
|
+
# @return [Hash{String => Sequel::Database}] the configured databases with these names, all of them if nil
|
|
282
|
+
def select_databases(names)
|
|
283
|
+
configured = configuration.databases.transform_keys(&:to_s)
|
|
284
|
+
return configured if names.nil?
|
|
285
|
+
|
|
286
|
+
names = Array(names).map(&:to_s)
|
|
287
|
+
unknown = names - configured.keys
|
|
288
|
+
unless unknown.empty?
|
|
289
|
+
raise ArgumentError, "unknown databases #{unknown.inspect}, configured are #{configured.keys.inspect}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
configured.slice(*names)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
data/sig/tape_deck.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tape_deck
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.beta1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Robert Schulze
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bigdecimal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sequel
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
description: Snapshots your databases around an action and stores the row-level diff
|
|
41
|
+
as a YAML tape. Later runs of the same action are verified against the tape, so
|
|
42
|
+
you notice when a change or update alters what gets written. VCR-style API, works
|
|
43
|
+
with Sequel and MySQL.
|
|
44
|
+
email:
|
|
45
|
+
- robert@dotless.de
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- ".devcontainer/compose.yml"
|
|
51
|
+
- ".devcontainer/devcontainer.json"
|
|
52
|
+
- ".yardopts"
|
|
53
|
+
- CHANGELOG.md
|
|
54
|
+
- CODE_OF_CONDUCT.md
|
|
55
|
+
- DEVELOPMENT.md
|
|
56
|
+
- LICENSE.txt
|
|
57
|
+
- README.md
|
|
58
|
+
- Rakefile
|
|
59
|
+
- lib/tape_deck.rb
|
|
60
|
+
- lib/tape_deck/configuration.rb
|
|
61
|
+
- lib/tape_deck/errors.rb
|
|
62
|
+
- lib/tape_deck/filter.rb
|
|
63
|
+
- lib/tape_deck/patterns.rb
|
|
64
|
+
- lib/tape_deck/recorder.rb
|
|
65
|
+
- lib/tape_deck/session.rb
|
|
66
|
+
- lib/tape_deck/tape.rb
|
|
67
|
+
- lib/tape_deck/version.rb
|
|
68
|
+
- sig/tape_deck.rbs
|
|
69
|
+
homepage: https://github.com/fnordfish/tape_deck
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
homepage_uri: https://github.com/fnordfish/tape_deck
|
|
74
|
+
documentation_uri: https://fnordfish.github.io/tape_deck/docs/v0.1.0.beta1/
|
|
75
|
+
changelog_uri: https://github.com/fnordfish/tape_deck/blob/main/CHANGELOG.md
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: 3.2.0
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 4.0.23
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Records what an action changes in your databases, and checks later runs against
|
|
94
|
+
it.
|
|
95
|
+
test_files: []
|