tdp 1.0.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/bin/tdp +41 -0
- data/lib/tdp.rb +442 -0
- metadata +102 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 87527ee02b194f28bbf5a6a8c642884969b9e9ca
|
4
|
+
data.tar.gz: e19033289f7d1665f3be87e891fba2c14d30f00c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ef74cbb9f6cffba8d574af1c297c63ca34ce6e890fcacf69f95266992930615a5835f0ec88c4a7c80fb3a42710c73e88d76ffad916d34a29b1458873e6c55402
|
7
|
+
data.tar.gz: 26c3a905c281cdd957138b7b16090b6867b00227048caf44cf6b2b161f54f6bd99af8a58299467045c1bbeb603bb8373d878d4ec2f228fbc4e68750fddba5606
|
data/bin/tdp
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/tdp'
|
4
|
+
|
5
|
+
HELP_BANNER = %(
|
6
|
+
Usage: tdp <command> <database url> [<patches #1> [<patches #2> ...]]
|
7
|
+
|
8
|
+
where
|
9
|
+
<command> is one of:
|
10
|
+
* bootstrap
|
11
|
+
* upgrade
|
12
|
+
* retrofit
|
13
|
+
* validate_upgradable
|
14
|
+
* validate_compatible
|
15
|
+
|
16
|
+
<database url> is database url, e.g.
|
17
|
+
sqlite://test.db
|
18
|
+
postgres://user:password@host:port/database_name
|
19
|
+
|
20
|
+
<patches #N> is a name of .sql file or a directory with .sql files
|
21
|
+
|
22
|
+
).lstrip.freeze
|
23
|
+
|
24
|
+
SUPPORTED_COMMANDS = %w(
|
25
|
+
bootstrap upgrade retrofit validate_upgradable validate_compatible
|
26
|
+
).freeze
|
27
|
+
|
28
|
+
def help
|
29
|
+
puts HELP_BANNER
|
30
|
+
exit(1)
|
31
|
+
end
|
32
|
+
|
33
|
+
help if ARGV.length < 2
|
34
|
+
help unless SUPPORTED_COMMANDS.include? ARGV[0]
|
35
|
+
|
36
|
+
begin
|
37
|
+
TDP.execute(ARGV[1], ARGV[2..-1], &ARGV[0].to_sym)
|
38
|
+
rescue TDP::NotConfiguredError => e
|
39
|
+
puts e.message
|
40
|
+
exit(1)
|
41
|
+
end
|
data/lib/tdp.rb
ADDED
@@ -0,0 +1,442 @@
|
|
1
|
+
|
2
|
+
require 'digest'
|
3
|
+
require 'sequel'
|
4
|
+
|
5
|
+
##
|
6
|
+
# Tiny Database Patcher.
|
7
|
+
#
|
8
|
+
module TDP
|
9
|
+
##
|
10
|
+
# Raised when there is a record that patch was applied to
|
11
|
+
# the database but the patch itself doesn't exist in the
|
12
|
+
# schema definition.
|
13
|
+
#
|
14
|
+
class NotConfiguredError < RuntimeError
|
15
|
+
##
|
16
|
+
# * *patch_name* is the name of the patch
|
17
|
+
#
|
18
|
+
def initialize(patch_name)
|
19
|
+
super "No definition file for patch in database: #{patch_name}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Raised when patch exists in the schema definition but
|
25
|
+
# wasn't applied to the database.
|
26
|
+
#
|
27
|
+
class NotAppliedError < RuntimeError
|
28
|
+
# Problematic patch (a Patch object)
|
29
|
+
attr_reader :patch
|
30
|
+
|
31
|
+
##
|
32
|
+
# patch :: a problematic patch (a Patch object)
|
33
|
+
#
|
34
|
+
def initialize(patch)
|
35
|
+
super "Patch is not applied: #{patch.name}"
|
36
|
+
@patch = patch
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Raised when signature of the patch in database doesn't match
|
42
|
+
# the signature of the patch in schema definition.
|
43
|
+
#
|
44
|
+
class MismatchError < RuntimeError
|
45
|
+
# Problematic patch (a Patch object)
|
46
|
+
attr_reader :patch
|
47
|
+
|
48
|
+
##
|
49
|
+
# patch :: a problematic patch (a Patch object)
|
50
|
+
#
|
51
|
+
def initialize(patch)
|
52
|
+
super "Applied patch doesn't match definition: #{patch.name}"
|
53
|
+
@patch = patch
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
##
|
58
|
+
# Raised when schema definition contains multiple patches
|
59
|
+
# with same name and different content.
|
60
|
+
#
|
61
|
+
class ContradictionError < RuntimeError
|
62
|
+
# Contradicting patches (Patch objects)
|
63
|
+
attr_reader :patches
|
64
|
+
|
65
|
+
##
|
66
|
+
# patches :: an array of problematic patches (Patch objects)
|
67
|
+
def initialize(patches)
|
68
|
+
super('Patches with same name and different content: ' +
|
69
|
+
patches.map(&:full_filename).join(' / ')
|
70
|
+
)
|
71
|
+
@patches = patches.clone.freeze
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# A single patch.
|
77
|
+
#
|
78
|
+
class Patch
|
79
|
+
# Content of the patch.
|
80
|
+
attr_reader :content
|
81
|
+
|
82
|
+
# Full name of the patch file.
|
83
|
+
attr_reader :full_filename
|
84
|
+
|
85
|
+
# SHA-256 hash of content.
|
86
|
+
attr_reader :signature
|
87
|
+
|
88
|
+
# Name of the patch.
|
89
|
+
attr_reader :name
|
90
|
+
|
91
|
+
##
|
92
|
+
# full_filename :: full path to +.sql+ file
|
93
|
+
#
|
94
|
+
def initialize(full_filename)
|
95
|
+
@full_filename = full_filename
|
96
|
+
_, @name = File.split(full_filename)
|
97
|
+
@content = File.read(full_filename)
|
98
|
+
@signature = Digest::SHA256.hexdigest(@content)
|
99
|
+
end
|
100
|
+
|
101
|
+
##
|
102
|
+
# Returns true if patch is volatile.
|
103
|
+
#
|
104
|
+
def volatile?
|
105
|
+
TDP.volatile_patch_file?(@name)
|
106
|
+
end
|
107
|
+
|
108
|
+
##
|
109
|
+
# Returns true if patch is permanent.
|
110
|
+
#
|
111
|
+
def permanent?
|
112
|
+
TDP.permanent_patch_file?(@name)
|
113
|
+
end
|
114
|
+
|
115
|
+
##
|
116
|
+
# Comparison function. Any permanent patch takes precedence
|
117
|
+
# over any volatile one, if both patches are permanent or both
|
118
|
+
# are volatile, ordering is based on name.
|
119
|
+
#
|
120
|
+
def <=>(other)
|
121
|
+
return -1 if permanent? && other.volatile?
|
122
|
+
return 1 if volatile? && other.permanent?
|
123
|
+
@name <=> other.name
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
##
|
128
|
+
# A set of patches.
|
129
|
+
#
|
130
|
+
class PatchSet
|
131
|
+
def initialize
|
132
|
+
@patches = {}
|
133
|
+
end
|
134
|
+
|
135
|
+
##
|
136
|
+
# Adds a patch to the set. Raises ContradictionError in case
|
137
|
+
# if patch set already contains a patch with the same name and
|
138
|
+
# different content.
|
139
|
+
#
|
140
|
+
# patch :: Patch object to add
|
141
|
+
#
|
142
|
+
def <<(patch)
|
143
|
+
known_patch = @patches[patch.name]
|
144
|
+
if known_patch.nil?
|
145
|
+
@patches[patch.name] = patch
|
146
|
+
elsif patch.content != known_patch.content
|
147
|
+
raise ContradictionError, [known_patch, patch]
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
##
|
152
|
+
# Calls the given block once for each patch in collection,
|
153
|
+
# passing that element as a parameter.
|
154
|
+
#
|
155
|
+
# Ordering of the patches is: first, all permanent patches
|
156
|
+
# alphanumerically sorted by name, then all volatile patches
|
157
|
+
# sorted in the same way.
|
158
|
+
#
|
159
|
+
def each
|
160
|
+
@patches.values.sort.each { |patch| yield patch }
|
161
|
+
end
|
162
|
+
|
163
|
+
##
|
164
|
+
# Returns an array of patches for which given block returns
|
165
|
+
# a true value.
|
166
|
+
#
|
167
|
+
# Ordering of patches is same as in #self.each method.
|
168
|
+
#
|
169
|
+
def select
|
170
|
+
@patches.values.sort.select { |patch| yield patch }
|
171
|
+
end
|
172
|
+
|
173
|
+
##
|
174
|
+
# Retrieves Patch by name. If there's no patch with this
|
175
|
+
# name, returns nil.
|
176
|
+
#
|
177
|
+
def [](name)
|
178
|
+
@patches[name]
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
##
|
183
|
+
# Data access object that encapsulates all operations with
|
184
|
+
# the database.
|
185
|
+
class DAO
|
186
|
+
# Sequel::Database object
|
187
|
+
attr_reader :db
|
188
|
+
|
189
|
+
##
|
190
|
+
# Creates a new DAO object.
|
191
|
+
#
|
192
|
+
# db :: must be either of:
|
193
|
+
# * instance of Sequel::Database class
|
194
|
+
# * database URL that can be passed to Sequel.connect()
|
195
|
+
#
|
196
|
+
def initialize(db)
|
197
|
+
case db
|
198
|
+
when Sequel::Database
|
199
|
+
@db = db
|
200
|
+
when String
|
201
|
+
@db = Sequel.connect(db)
|
202
|
+
else
|
203
|
+
raise ArgumentError, "Invalid argument #{db} of class #{db.class}"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
##
|
208
|
+
# Initializes database tables for keeping track of applied
|
209
|
+
# patches.
|
210
|
+
#
|
211
|
+
def bootstrap
|
212
|
+
return if @db.table_exists?(:tdp_patch)
|
213
|
+
@db << %{
|
214
|
+
CREATE TABLE tdp_patch(
|
215
|
+
name VARCHAR PRIMARY KEY
|
216
|
+
, signature VARCHAR NOT NULL
|
217
|
+
)
|
218
|
+
}
|
219
|
+
end
|
220
|
+
|
221
|
+
##
|
222
|
+
# Fetches the information about applied patches and
|
223
|
+
# returns it as { name => signature } hash.
|
224
|
+
#
|
225
|
+
def applied_patches
|
226
|
+
result = {}
|
227
|
+
@db[:tdp_patch].select(:name, :signature).each do |row|
|
228
|
+
result[row[:name]] = row[:signature]
|
229
|
+
end
|
230
|
+
result
|
231
|
+
end
|
232
|
+
|
233
|
+
##
|
234
|
+
# Looks up a signature of a patch by its name.
|
235
|
+
#
|
236
|
+
def patch_signature(name)
|
237
|
+
row = @db[:tdp_patch].select(:signature).where(name: name).first
|
238
|
+
row.nil? ? nil : row[:signature]
|
239
|
+
end
|
240
|
+
|
241
|
+
##
|
242
|
+
# Applies a patch (a Patch object).
|
243
|
+
#
|
244
|
+
def apply(patch)
|
245
|
+
@db << patch.content
|
246
|
+
register(patch)
|
247
|
+
rescue Sequel::Error => ex
|
248
|
+
raise Sequel::Error,
|
249
|
+
"Failed to apply patch #{patch.full_filename}: #{ex}"
|
250
|
+
end
|
251
|
+
|
252
|
+
##
|
253
|
+
# Registers a patch (a Patch object) as applied.
|
254
|
+
#
|
255
|
+
def register(patch)
|
256
|
+
q = @db[:tdp_patch].where(name: patch.name)
|
257
|
+
if q.empty?
|
258
|
+
@db[:tdp_patch].insert(
|
259
|
+
name: patch.name,
|
260
|
+
signature: patch.signature
|
261
|
+
)
|
262
|
+
else
|
263
|
+
q.update(signature: patch.signature)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
##
|
268
|
+
# Erases all data about applied patches.
|
269
|
+
#
|
270
|
+
def erase
|
271
|
+
@db[:tdp_patch].delete
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
##
|
276
|
+
# Main class of the package.
|
277
|
+
#
|
278
|
+
class Engine
|
279
|
+
##
|
280
|
+
# Creates a new Engine object.
|
281
|
+
#
|
282
|
+
# db :: must be one of:
|
283
|
+
# * instance of Sequel::Database class
|
284
|
+
# * database URL that can be passed to Sequel.connect()
|
285
|
+
#
|
286
|
+
def initialize(db)
|
287
|
+
@dao = DAO.new(db)
|
288
|
+
@patches = PatchSet.new
|
289
|
+
end
|
290
|
+
|
291
|
+
##
|
292
|
+
# Registers patch files in the engine.
|
293
|
+
#
|
294
|
+
# filename :: may be either a name of .sql file or a name
|
295
|
+
# of directory (which would be recursively scanned for .sql
|
296
|
+
# files)
|
297
|
+
#
|
298
|
+
def <<(filename)
|
299
|
+
if File.directory?(filename)
|
300
|
+
Dir.foreach(filename) do |x|
|
301
|
+
self << File.join(filename, x) unless x.start_with?('.')
|
302
|
+
end
|
303
|
+
elsif TDP.patch_file?(filename)
|
304
|
+
@patches << Patch.new(filename)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
##
|
309
|
+
# Initializes database tables for keeping track of applied
|
310
|
+
# patches.
|
311
|
+
#
|
312
|
+
def bootstrap
|
313
|
+
@dao.bootstrap
|
314
|
+
end
|
315
|
+
|
316
|
+
##
|
317
|
+
# Produces an ordered list of patches that need to be applied.
|
318
|
+
#
|
319
|
+
# May raise MismatchError in case if signatures of any permanent
|
320
|
+
# patches that are present in the definition don't match
|
321
|
+
# ones of the patches applied to the database.
|
322
|
+
#
|
323
|
+
def plan
|
324
|
+
@patches.select do |patch|
|
325
|
+
signature = @dao.patch_signature(patch.name)
|
326
|
+
next false if signature == patch.signature
|
327
|
+
next true if signature.nil? || patch.volatile?
|
328
|
+
raise MismatchError, patch
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
##
|
333
|
+
# Applies all changes that need to be applied.
|
334
|
+
#
|
335
|
+
def upgrade
|
336
|
+
validate_upgradable
|
337
|
+
plan.each { |patch| @dao.apply(patch) }
|
338
|
+
end
|
339
|
+
|
340
|
+
##
|
341
|
+
# Validates that it is safe run upgrade the database.
|
342
|
+
# In particular, the following conditions must be met:
|
343
|
+
# * there is an .sql file for every patch that is marked
|
344
|
+
# as applied to database
|
345
|
+
# * every permanent patch has same signature as the corresponding
|
346
|
+
# .sql file.
|
347
|
+
#
|
348
|
+
# May raise MismatchError or NotConfiguredError in case if those
|
349
|
+
# conditions aren't met.
|
350
|
+
#
|
351
|
+
def validate_upgradable
|
352
|
+
@dao.applied_patches.each_key do |name|
|
353
|
+
raise NotConfiguredError, name unless @patches[name]
|
354
|
+
end
|
355
|
+
plan
|
356
|
+
end
|
357
|
+
|
358
|
+
##
|
359
|
+
# Validates that all patches are applied to the database.
|
360
|
+
#
|
361
|
+
# May raise MismatchError, NotConfiguredError or NotAppliedError
|
362
|
+
# in case if there are any problems.
|
363
|
+
#
|
364
|
+
def validate_compatible
|
365
|
+
validate_upgradable
|
366
|
+
|
367
|
+
@patches.each do |patch|
|
368
|
+
signature = @dao.patch_signature(patch.name)
|
369
|
+
next if signature == patch.signature
|
370
|
+
raise NotAppliedError, patch if signature.nil?
|
371
|
+
raise MismatchError, patch
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
##
|
376
|
+
# Erases existing data about applied patches and replaces
|
377
|
+
# it with configured schema.
|
378
|
+
#
|
379
|
+
def retrofit
|
380
|
+
@dao.erase
|
381
|
+
@patches.each do |patch|
|
382
|
+
@dao.register(patch)
|
383
|
+
end
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
##
|
388
|
+
# Returns true if argument is a valid file name of a permanent
|
389
|
+
# patch.
|
390
|
+
#
|
391
|
+
# To qualify for a permanent patch, file name must start with
|
392
|
+
# a number and end with ".sql" extension.
|
393
|
+
# E.g. _001-initial-schema.sql_ or _201611001_add_accounts_table.sql_
|
394
|
+
#
|
395
|
+
def self.permanent_patch_file?(filename)
|
396
|
+
/^\d+.*\.sql$/ =~ filename
|
397
|
+
end
|
398
|
+
|
399
|
+
##
|
400
|
+
# Returns true if argument is a valid file name of a volatile
|
401
|
+
# patch.
|
402
|
+
#
|
403
|
+
# To qualify for a volatile patch, file name must end with ".sql"
|
404
|
+
# exception and *NOT* start with a number (otherwise it'd qualify
|
405
|
+
# for permanent patch instead). E.g. _views.sql_
|
406
|
+
# or _stored-procedures.sql_
|
407
|
+
#
|
408
|
+
def self.volatile_patch_file?(filename)
|
409
|
+
/^[^\d]+.*\.sql$/ =~ filename
|
410
|
+
end
|
411
|
+
|
412
|
+
##
|
413
|
+
# Returns true if argument is a valid file name of a patch.
|
414
|
+
#
|
415
|
+
# To qualify for a patch, file name must end with ".sql"
|
416
|
+
# extension.
|
417
|
+
#
|
418
|
+
def self.patch_file?(filename)
|
419
|
+
filename.end_with?('.sql')
|
420
|
+
end
|
421
|
+
|
422
|
+
##
|
423
|
+
# Main entrypoint of TDP package.
|
424
|
+
#
|
425
|
+
# Initializes an Engine with given database details and
|
426
|
+
# schema files locations and then calls the given block
|
427
|
+
# passing engine as a parameter.
|
428
|
+
#
|
429
|
+
# db :: must be one of:
|
430
|
+
# * instance of Sequel::Database class
|
431
|
+
# * database URL that can be passed to Sequel.connect()
|
432
|
+
#
|
433
|
+
# *paths* must be an array of names of .sql files and directories
|
434
|
+
# containing those files
|
435
|
+
#
|
436
|
+
def self.execute(db, paths = [])
|
437
|
+
engine = Engine.new(db)
|
438
|
+
paths.each { |x| engine << x }
|
439
|
+
engine.bootstrap
|
440
|
+
yield engine
|
441
|
+
end
|
442
|
+
end
|
metadata
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tdp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ivan Appel
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-11-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sequel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.40'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.40'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: simplecov
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.12'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.12'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: sqlite3
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.3'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.3'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: test-unit
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.2'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.2'
|
69
|
+
description: Tool for pure-SQL database migrations
|
70
|
+
email: ivan.appel@gmail.com
|
71
|
+
executables:
|
72
|
+
- tdp
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- bin/tdp
|
77
|
+
- lib/tdp.rb
|
78
|
+
homepage: http://github.com/geekyfox/tdp
|
79
|
+
licenses:
|
80
|
+
- MIT
|
81
|
+
metadata: {}
|
82
|
+
post_install_message:
|
83
|
+
rdoc_options: []
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - ">="
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
requirements: []
|
97
|
+
rubyforge_project:
|
98
|
+
rubygems_version: 2.4.8
|
99
|
+
signing_key:
|
100
|
+
specification_version: 4
|
101
|
+
summary: Tiny Database Patcher
|
102
|
+
test_files: []
|