tarantool-schema 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 431a9d499bae2bc10348a4780b10ea5793e4debd8b021357f5f0a9edca753e00
4
+ data.tar.gz: 899bfb866f10c504ba4198899d2a42f850ae0b4a2fdb4c87a920f7c029097591
5
+ SHA512:
6
+ metadata.gz: d07771270e5545f01d696e9600de60b09c861f87e134feaad9e566ad42ee679bed2a512a302b28387bd44a219ef32d518c0a87d245af30088e2da1cc5fb2360a
7
+ data.tar.gz: df474e074921d8678a7c55f976de5e661dd9eba828591a4247cc6a30c2ef95c1d75b245df42fa567fa4e846cd29085487194c6cf8b7af6d1f041213f3b2499de
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-17)
4
+
5
+ First release.
6
+
7
+ - `Tarantool::Schema::Definition`: `ensure_space` and `ensure_index` with `plan` and `apply` over an admin
8
+ `Tarantool::Client`. Creates missing spaces, indexes and primary key sequences; raises `MismatchError` with a
9
+ diff when the live schema differs, before creating anything. Never alters or drops.
10
+ - `Definition#to_h`: the normalized declaration, with defaults filled in and part types inherited; tarantool-model's
11
+ `Testing::MemoryStorage.from_definition` accepts the definition.
12
+ - Depends on `tarantool-client ~> 0.2.0`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Starlexxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # tarantool-schema
2
+
3
+ Idempotent schema declaration for Tarantool 2.11+ and 3.x, meant for deploys. You declare spaces, indexes and
4
+ primary key sequences in Ruby; `apply` creates whatever is missing and checks everything that already exists.
5
+ If the live schema differs from the declaration, it raises an error with a diff and creates nothing. It never
6
+ alters or drops anything: changing a field type or rebuilding an index is a migration and is out of scope.
7
+
8
+ ## Installation
9
+
10
+ Requirements: Ruby >= 3.2, Tarantool 2.11+ or 3.x.
11
+
12
+ ```ruby
13
+ gem 'tarantool-schema'
14
+ ```
15
+
16
+ ```ruby
17
+ require 'tarantool/schema'
18
+ ```
19
+
20
+ ## Declaring a schema
21
+
22
+ ```ruby
23
+ module MyApp
24
+ SCHEMA = Tarantool::Schema::Definition.new do
25
+ ensure_space :codes, engine: :memtx,
26
+ format: [
27
+ { name: :id, type: :unsigned },
28
+ { name: :code, type: :string },
29
+ { name: :expires_at, type: :unsigned, is_nullable: true }
30
+ ],
31
+ indexes: [
32
+ { name: :primary, parts: [:id], sequence: true },
33
+ { name: :by_code, parts: [:code] }
34
+ ]
35
+
36
+ ensure_index :codes, :by_expiry, parts: [:expires_at], unique: false
37
+ end
38
+ end
39
+ ```
40
+
41
+ The block runs with the definition as `self`, then the definition is frozen: calling `ensure_*` later raises
42
+ `FrozenError`, and loading the file again builds a new definition instead of growing the old one.
43
+
44
+ - `ensure_space(name, format:, indexes:, engine: :memtx)`. `format` uses Tarantool's own keys: `name`, `type`
45
+ (omitted means `any`), `is_nullable` (default false). `indexes` are in declaration order; the first one is the
46
+ primary key.
47
+ - Index keys: `name`, `parts`, `unique` (default true), `type` (`tree` by default, `hash`, `bitset`, `rtree`),
48
+ `sequence` (default false). A part is a field name or `{ field:, type:, is_nullable:, path: }`. A part's type
49
+ and nullability default to the field's; a part with a JSON `path` must declare its type.
50
+ - `ensure_index(space, name, **options)` adds an index to a space declared in the same definition. For a space
51
+ owned by someone else it only adds the index: the space must already exist, and part types come from its
52
+ live format.
53
+
54
+ ### Validation
55
+
56
+ These raise `ArgumentError` while the definition is built, before anything reaches the server:
57
+
58
+ - a space declared twice, or declared after an `ensure_index` for it; a duplicate field or index name;
59
+ - unknown keys anywhere, including the unsupported options listed below;
60
+ - type aliases or unknown types. Only canonical names are accepted: `any unsigned string integer number double
61
+ boolean decimal uuid datetime interval varbinary scalar array map`. Use `string`, not `str`;
62
+ - a part that refers to a field missing from the format;
63
+ - vinyl spaces with anything but `tree` indexes;
64
+ - unique `rtree`/`bitset` indexes, and `rtree`/`bitset`, non-unique or nullable primary keys;
65
+ - `sequence: true` anywhere except a primary key with exactly one `unsigned` or `integer` part;
66
+ - `tree` or `hash` index parts of type `any`, `interval`, `array` or `map`; a `hash` index that is not unique, or
67
+ that has a nullable part (`tree` allows nullable parts).
68
+
69
+ ### Not supported
70
+
71
+ Declaring any of these raises `ArgumentError`, so nobody assumes they were applied:
72
+
73
+ - space: `is_sync`, `temporary`, `is_local`, `field_count`, `id`, `user`, `constraint`, `foreign_key`;
74
+ - field: `default`, `default_func`, `constraint`, `foreign_key`, `collation`;
75
+ - index: `id`, `hint`, `func`, `dimension`, `distance`, vinyl tuning (`page_size`, `range_size`,
76
+ `run_count_per_level`, `run_size_ratio`, `bloom_fpr`);
77
+ - part: `collation`, `sort_order`, `exclude_null`.
78
+
79
+ Options that are set on a live space but that you did not declare are ignored when comparing.
80
+
81
+ ## Applying
82
+
83
+ ```ruby
84
+ client = Tarantool::Client.new(ENV.fetch('TARANTOOL_ADMIN_URI'))
85
+
86
+ # codes already exists from an earlier deploy; only by_expiry is new
87
+ MyApp::SCHEMA.plan(client) # => ["index codes.by_expiry"], writes nothing
88
+ MyApp::SCHEMA.apply(client) # => ["index codes.by_expiry"]
89
+ MyApp::SCHEMA.apply(client) # => []
90
+ ```
91
+
92
+ Connect to the read-write instance. `apply`:
93
+
94
+ 1. reads every declared space (one `eval` per space, with the name passed as an argument);
95
+ 2. compares it with the declaration and collects the differences across all spaces;
96
+ 3. if there are any, raises `Tarantool::Schema::MismatchError` and creates nothing;
97
+ 4. creates the missing spaces and indexes in declaration order, with `if_not_exists` so two deploys racing each
98
+ other do not fail;
99
+ 5. reads everything again and raises `MismatchError` if something is still missing or differs, for example
100
+ when another deploy created the space differently in the meantime. Racing another deploy can also surface
101
+ as `Tarantool::ServerError` raised directly from `create_index`, before this re-verification runs.
102
+
103
+ Each space and index is created with its own `eval`, so if one fails partway through (for example a privilege
104
+ missing for a later index), the ones created before it stay; running `apply` again is idempotent and picks up
105
+ where it left off.
106
+
107
+ Comparison is strict about what you declared: engine; format (field count, and for each position the name,
108
+ type and nullability); the primary index name; and for each declared index its type, uniqueness, sequence and
109
+ parts. Indexes in the live space that you did not declare are ignored. Type aliases in a live format (`str`,
110
+ `num`) count as their canonical names.
111
+
112
+ ```
113
+ schema mismatch:
114
+ space codes:
115
+ engine: declared memtx, actual vinyl
116
+ field 2 code: type declared string, actual unsigned
117
+ index by_code: unique declared true, actual false
118
+ ```
119
+
120
+ `MismatchError#mismatches` returns the same lines as an Array, each prefixed with `space <name>: `.
121
+
122
+ ## Rake
123
+
124
+ ```ruby
125
+ namespace :tarantool do
126
+ namespace :schema do
127
+ def with_schema_client
128
+ require 'tarantool/schema'
129
+ require_relative 'config/tarantool_schema' # defines MyApp::SCHEMA
130
+ client = Tarantool::Client.new(ENV.fetch('TARANTOOL_ADMIN_URI'))
131
+ yield client
132
+ ensure
133
+ client&.close
134
+ end
135
+
136
+ desc 'Show what tarantool:schema:apply would create; fail on drift'
137
+ task :check do
138
+ with_schema_client { |client| puts MyApp::SCHEMA.plan(client) }
139
+ end
140
+
141
+ desc 'Create missing Tarantool spaces and indexes; fail on drift'
142
+ task :apply do
143
+ with_schema_client { |client| puts MyApp::SCHEMA.apply(client) }
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ ## Privileges
150
+
151
+ The deploy user needs these privileges (tested on 2.11 and 3.x):
152
+
153
+ | Privilege | 2.11 | 3.x |
154
+ |---|---|---|
155
+ | eval | `execute` on `universe` | `execute` on `lua_eval` |
156
+ | create spaces | `create` on `space` | same |
157
+ | primary key sequences | `create` on `sequence` | same |
158
+ | system spaces | `read,write` on spaces `_space`, `_index`, `_sequence`, `_space_sequence` | same |
159
+
160
+ ```lua
161
+ box.schema.user.create('deployer', {password = '...'})
162
+ box.schema.user.grant('deployer', 'execute', 'lua_eval') -- 2.11: 'execute', 'universe'
163
+ box.schema.user.grant('deployer', 'create', 'space')
164
+ box.schema.user.grant('deployer', 'create', 'sequence')
165
+ for _, name in ipairs({'_space', '_index', '_sequence', '_space_sequence'}) do
166
+ box.schema.user.grant('deployer', 'read,write', 'space', name)
167
+ end
168
+ ```
169
+
170
+ On 2.11, only `admin` can grant privileges on `universe`; a `super` user cannot. Adding an index to a space
171
+ owned by another user also needs `alter` on that space. The simplest setup is a dedicated deploy user that owns
172
+ the application spaces, or one with the `super` role.
173
+
174
+ ## Sharing the declaration with tests
175
+
176
+ `Definition#to_h` returns the normalized declaration: names as Symbols, types as Strings, every default filled
177
+ in, part types inherited, and `ensure_index` entries merged into their spaces. Use it to build an in-memory
178
+ test double or any other representation of the schema from the same declaration used to deploy it.
179
+
180
+ Only spaces declared with `ensure_space` are in it: indexes added with `ensure_index` to a space declared
181
+ elsewhere have no spec here and are not included.
182
+
183
+ ```ruby
184
+ MyApp::SCHEMA.to_h
185
+ # => { codes: { engine: 'memtx',
186
+ # format: [{ name: :id, type: 'unsigned', is_nullable: false }, ...],
187
+ # indexes: [{ name: :primary, type: 'tree', unique: true, sequence: true,
188
+ # parts: [{ field: :id, type: 'unsigned', is_nullable: false, path: nil }] }, ...] } }
189
+ ```
190
+
191
+ tarantool-model's in-memory test storage accepts the definition as is, so tests run against the same declaration
192
+ as the deploy:
193
+
194
+ ```ruby
195
+ require 'tarantool/model/testing'
196
+
197
+ storage = Tarantool::Model::Testing::MemoryStorage.from_definition(MyApp::SCHEMA)
198
+ ```
199
+
200
+ MemoryStorage raises `ArgumentError` for JSON `path` parts and `rtree` or `bitset` indexes.
201
+
202
+ ## Errors
203
+
204
+ `Tarantool::Schema::MismatchError < Tarantool::Schema::Error < Tarantool::Error`. Server errors such as access
205
+ denied or a read-only instance are raised unchanged as `Tarantool::ServerError`.
206
+
207
+ ## Development
208
+
209
+ ```sh
210
+ podman compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
211
+ # or: docker compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
212
+
213
+ cd tarantool-schema
214
+ bundle exec rspec # unit
215
+ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec
216
+ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3302 bundle exec rspec
217
+ ```
218
+
219
+ ## Public API
220
+
221
+ `Tarantool::Schema::Definition` and the error classes above.
222
+
223
+ Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
224
+ and may change in any release.
225
+
226
+ ## License
227
+
228
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Differences between one space as declared and as it is live. For a space declared elsewhere (only its
6
+ # indexes are declared here) there is no spec: engine and format are not compared and index parts inherit
7
+ # from the live format.
8
+ # @api private
9
+ class Comparison
10
+ attr_reader :name, :space, :mismatches, :missing_indexes
11
+
12
+ def initialize(name:, indexes:, live:, space: nil)
13
+ @name = name
14
+ @space = space
15
+ @live = live
16
+ @mismatches = []
17
+ @missing_indexes = []
18
+ live ? compare(indexes) : absent(indexes)
19
+ end
20
+
21
+ def missing_space? = @live.nil? && !@space.nil?
22
+
23
+ private
24
+
25
+ def absent(indexes)
26
+ if @space
27
+ @missing_indexes.concat(indexes)
28
+ else
29
+ @mismatches << 'missing'
30
+ end
31
+ end
32
+
33
+ def compare(indexes)
34
+ compare_space if @space
35
+ indexes.each { |index| compare_index(index) }
36
+ end
37
+
38
+ def compare_space
39
+ @mismatches << "engine: declared #{@space.engine}, actual #{@live.engine}" if @space.engine != @live.engine
40
+ @mismatches.concat(FormatComparison.new(@space.format, @live.format).mismatches)
41
+ compare_primary
42
+ end
43
+
44
+ def compare_primary
45
+ declared = @space.indexes.first.name
46
+ actual = @live.primary&.name
47
+ @mismatches << "primary index: declared #{declared}, actual #{actual}" if actual && actual != declared
48
+ end
49
+
50
+ def compare_index(index)
51
+ index = resolve(index) unless @space
52
+ return unless index
53
+
54
+ live = @live.index(index.name)
55
+ return @missing_indexes << index unless live
56
+
57
+ @mismatches.concat(IndexComparison.new(index, live).mismatches)
58
+ end
59
+
60
+ def resolve(index)
61
+ fields = @live.fields
62
+ unknown = index.unknown_fields(fields)
63
+ return index.resolve(fields) if unknown.empty?
64
+
65
+ @mismatches << "index #{index.name}: unknown field #{unknown.join(', ')}"
66
+
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # What apply creates. Names and options are eval arguments, never part of the Lua source. if_not_exists keeps
6
+ # a deploy racing another one from failing; the re-verification after apply catches what the other one made.
7
+ # @api private
8
+ module Creation
9
+ SPACE_LUA = <<~LUA
10
+ local name, options = ...
11
+ box.schema.space.create(name, options)
12
+ LUA
13
+
14
+ INDEX_LUA = <<~LUA
15
+ local space, name, options = ...
16
+ box.space[space]:create_index(name, options)
17
+ LUA
18
+
19
+ Space = Data.define(:spec) do
20
+ def apply(client)
21
+ client.eval(SPACE_LUA, [spec.name.to_s, spec.to_lua.merge('if_not_exists' => true)])
22
+ end
23
+
24
+ def to_s = "space #{spec.name}"
25
+ end
26
+
27
+ Index = Data.define(:space, :spec) do
28
+ def apply(client)
29
+ client.eval(INDEX_LUA, [space.to_s, spec.name.to_s, spec.to_lua.merge('if_not_exists' => true)])
30
+ end
31
+
32
+ def to_s = "index #{space}.#{spec.name}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Spaces and indexes an application needs. Built once from a block and frozen, so a definition kept in a
6
+ # constant cannot grow when its file is loaded again. The block runs with the definition as self.
7
+ class Definition
8
+ def initialize(&block)
9
+ @spaces = {}
10
+ @external = {}
11
+ instance_exec(&block) if block
12
+ @spaces.transform_values! { |space| SpaceRules.check(space.resolve) }.freeze
13
+ @external.each_value(&:freeze).freeze
14
+ freeze
15
+ end
16
+
17
+ def ensure_space(name, format:, indexes:, engine: :memtx)
18
+ raise ArgumentError, 'space without name' if name.nil?
19
+
20
+ name = name.to_sym
21
+ raise ArgumentError, "space #{name} is already declared" if @spaces.key?(name)
22
+ raise ArgumentError, "space #{name}: declare it before its ensure_index" if @external.key?(name)
23
+
24
+ @spaces[name] = SpaceSpec.build(name, engine:, format:, indexes:)
25
+ nil
26
+ end
27
+
28
+ def ensure_index(space, name, **options)
29
+ raise ArgumentError, 'space without name' if space.nil?
30
+
31
+ space = space.to_sym
32
+ index = IndexSpec.build(name, options)
33
+ if @spaces.key?(space)
34
+ @spaces[space] = @spaces[space].add_index(index)
35
+ else
36
+ add_external(space, index)
37
+ end
38
+ nil
39
+ end
40
+
41
+ def to_h = @spaces.transform_values(&:to_h)
42
+
43
+ def plan(client) = build_plan(client).verify.creations.map(&:to_s)
44
+
45
+ # The second plan re-reads everything, so a space another deploy created differently in the meantime fails
46
+ # here instead of being reported as created.
47
+ def apply(client)
48
+ creations = build_plan(client).verify.creations
49
+ creations.each { |creation| creation.apply(client) }
50
+ build_plan(client).verify(complete: true)
51
+
52
+ creations.map(&:to_s)
53
+ end
54
+
55
+ private
56
+
57
+ def build_plan(client) = Plan.new(spaces: @spaces, external: @external, client:)
58
+
59
+ # A space declared elsewhere already has its primary key, so an index added here never gets a sequence.
60
+ def add_external(space, index)
61
+ if index.sequence
62
+ raise ArgumentError, "index #{index.name}: sequence is only allowed on the primary index, declare it " \
63
+ 'with ensure_space'
64
+ end
65
+ indexes = @external.fetch(space, [])
66
+ if indexes.any? { |i| i.name == index.name }
67
+ raise ArgumentError, "space #{space}: duplicate index #{index.name}"
68
+ end
69
+
70
+ @external[space] = [*indexes, index]
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Base class for errors raised by tarantool-schema.
6
+ class Error < Tarantool::Error; end
7
+
8
+ # The live schema differs from the declaration. apply raises it before creating anything, and again if what
9
+ # it created does not verify afterwards.
10
+ class MismatchError < Error
11
+ attr_reader :mismatches
12
+
13
+ def initialize(by_space)
14
+ @mismatches = by_space.flat_map { |space, lines| lines.map { |line| "space #{space}: #{line}" } }
15
+ super(describe(by_space))
16
+ end
17
+
18
+ private
19
+
20
+ def describe(by_space)
21
+ sections = by_space.map do |space, lines|
22
+ [" space #{space}:", *lines.map { |line| " #{line}" }].join("\n")
23
+ end
24
+
25
+ "schema mismatch:\n#{sections.join("\n")}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # One entry of a space format with Tarantool's defaults filled in. Also used for live format entries.
6
+ # @api private
7
+ FieldSpec = Data.define(:name, :type, :is_nullable) do
8
+ def self.build(raw)
9
+ raise ArgumentError, "format entry must be a Hash, got #{raw.inspect}" unless raw.is_a?(Hash)
10
+
11
+ context = "field #{raw[:name]}"
12
+ Options.check(raw, %i[name type is_nullable], context)
13
+ raise ArgumentError, 'format entry without name' if raw[:name].nil?
14
+
15
+ new(name: raw[:name].to_sym, type: Types.declared(raw.fetch(:type, 'any'), context),
16
+ is_nullable: Options.boolean(raw.fetch(:is_nullable, false), "#{context} is_nullable"))
17
+ end
18
+
19
+ def to_lua = { 'name' => name.to_s, 'type' => type, 'is_nullable' => is_nullable }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Differences between a declared format and the live one, field by field in order.
6
+ # @api private
7
+ class FormatComparison
8
+ ATTRIBUTES = %i[name type is_nullable].freeze
9
+
10
+ def initialize(declared, live)
11
+ @declared = declared
12
+ @live = live
13
+ end
14
+
15
+ def mismatches
16
+ fields = @declared.zip(@live).each_with_index.flat_map do |(declared, live), index|
17
+ live ? field_mismatches(index + 1, declared, live) : []
18
+ end
19
+
20
+ count_mismatch + fields
21
+ end
22
+
23
+ private
24
+
25
+ def count_mismatch
26
+ return [] if @declared.size == @live.size
27
+
28
+ ["format: declared #{@declared.size} fields, actual #{@live.size}"]
29
+ end
30
+
31
+ def field_mismatches(number, declared, live)
32
+ ATTRIBUTES.filter_map do |attribute|
33
+ expected = declared.public_send(attribute)
34
+ actual = live.public_send(attribute)
35
+ "field #{number} #{declared.name}: #{attribute} declared #{expected}, actual #{actual}" if expected != actual
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Differences between a declared index, its parts resolved, and the live index of the same name.
6
+ # @api private
7
+ class IndexComparison
8
+ ATTRIBUTES = %i[type unique sequence].freeze
9
+
10
+ def initialize(declared, live)
11
+ @declared = declared
12
+ @live = live
13
+ end
14
+
15
+ def mismatches
16
+ ATTRIBUTES.filter_map { |attribute| attribute_mismatch(attribute) } + parts_mismatch
17
+ end
18
+
19
+ private
20
+
21
+ def attribute_mismatch(attribute)
22
+ expected = @declared.public_send(attribute)
23
+ actual = @live.public_send(attribute)
24
+
25
+ "index #{@declared.name}: #{attribute} declared #{expected}, actual #{actual}" if expected != actual
26
+ end
27
+
28
+ def parts_mismatch
29
+ return [] if @declared.parts == @live.parts
30
+
31
+ ["index #{@declared.name}: parts declared #{describe(@declared.parts)}, actual #{describe(@live.parts)}"]
32
+ end
33
+
34
+ def describe(parts)
35
+ parts.map { |part| [part.field, part.path, part.type, ('nullable' if part.is_nullable)].compact.join(' ') }
36
+ .join(', ')
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # A declared index with create_index defaults filled in. Rules that depend on the space (primary key,
6
+ # engine, sequence) live in SpaceRules.
7
+ # @api private
8
+ IndexSpec = Data.define(:name, :type, :unique, :parts, :sequence) do
9
+ def self.build(name, raw)
10
+ raise ArgumentError, 'index without name' if name.nil?
11
+
12
+ context = "index #{name}"
13
+ Options.check(raw, %i[parts unique type sequence], context)
14
+ index = new(name: name.to_sym, type: type(raw, context), parts: parts(raw[:parts], context),
15
+ unique: Options.boolean(raw.fetch(:unique, true), "#{context} unique"),
16
+ sequence: Options.boolean(raw.fetch(:sequence, false), "#{context} sequence"))
17
+ check_unique(index, context)
18
+ KeyRules.check(index, nil)
19
+
20
+ index
21
+ end
22
+
23
+ def self.type(raw, context)
24
+ type = raw.fetch(:type, :tree).to_s
25
+ raise ArgumentError, "#{context}: unknown type #{type.inspect}" unless IndexSpec::TYPES.include?(type)
26
+
27
+ -type
28
+ end
29
+
30
+ def self.parts(raw, context)
31
+ raise ArgumentError, "#{context}: parts must be a non-empty Array" unless raw.is_a?(Array) && raw.any?
32
+
33
+ raw.map { |part| PartSpec.build(part) }.freeze
34
+ end
35
+
36
+ def self.check_unique(index, context)
37
+ return unless index.unique && IndexSpec::NON_UNIQUE.include?(index.type)
38
+
39
+ raise ArgumentError, "#{context}: #{index.type} index can not be unique, set unique: false"
40
+ end
41
+ private_class_method :type, :parts, :check_unique
42
+
43
+ def unknown_fields(fields) = parts.map(&:field).reject { |field| fields.key?(field) }
44
+
45
+ def resolve(fields) = with(parts: parts.map { |part| part.inherit(fields.fetch(part.field)) }.freeze)
46
+
47
+ def to_h = super.merge(parts: parts.map(&:to_h))
48
+
49
+ def to_lua = { 'type' => type, 'unique' => unique, 'parts' => parts.map(&:to_lua), 'sequence' => sequence }
50
+ end
51
+
52
+ IndexSpec::TYPES = %w[tree hash bitset rtree].freeze
53
+ IndexSpec::NON_UNIQUE = %w[bitset rtree].freeze
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Reads one space from box.space. The name travels as an eval argument, never inside the Lua source.
6
+ # @api private
7
+ module Inspector
8
+ LUA = <<~LUA
9
+ local space = box.space[...]
10
+ if space == nil then return nil end
11
+ local indexes = {}
12
+ for id, index in pairs(space.index) do
13
+ if type(id) == 'number' then
14
+ local parts = {}
15
+ for _, part in ipairs(index.parts) do
16
+ table.insert(parts, {fieldno = part.fieldno, type = part.type, is_nullable = part.is_nullable,
17
+ path = part.path})
18
+ end
19
+ table.insert(indexes, {id = id, name = index.name, type = index.type, unique = index.unique,
20
+ parts = parts, sequence = index.sequence_id ~= nil})
21
+ end
22
+ end
23
+ return {engine = space.engine, format = space:format(), indexes = indexes}
24
+ LUA
25
+
26
+ def self.call(client, name)
27
+ raw = client.eval(LUA, [name.to_s]).first
28
+
29
+ raw && LiveSpace.from_eval(raw)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Key part rules Tarantool enforces on tree and hash indexes, verified live on 2.11 and 3.x. Checked before
6
+ # resolution, on what IndexSpec.build already knows, and again after, on a resolved SpaceSpec where parts
7
+ # have inherited their type and nullability from the format.
8
+ # @api private
9
+ module KeyRules
10
+ UNSUPPORTED_PART_TYPES = %w[any interval array map].freeze
11
+
12
+ module_function
13
+
14
+ def check(index, context)
15
+ return unless %w[tree hash].include?(index.type)
16
+
17
+ index.parts.each { |part| check_part_type(index, part, context) }
18
+ check_unique(index, context)
19
+ index.parts.each { |part| check_nullable(index, part, context) }
20
+ end
21
+
22
+ def check_part_type(index, part, context)
23
+ return unless part.type && UNSUPPORTED_PART_TYPES.include?(part.type)
24
+
25
+ raise error(index, context, "part #{part.field} of type #{part.type} can not be indexed by #{index.type}")
26
+ end
27
+
28
+ def check_unique(index, context)
29
+ return unless index.type == 'hash' && !index.unique
30
+
31
+ raise error(index, context, 'hash index must be unique')
32
+ end
33
+
34
+ def check_nullable(index, part, context)
35
+ return unless index.type == 'hash' && part.is_nullable
36
+
37
+ raise error(index, context, "hash index can not have nullable part #{part.field}")
38
+ end
39
+
40
+ def error(index, context, message)
41
+ ArgumentError.new("#{"#{context}: " if context}index #{index.name}: #{message}")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # A space as the server has it, normalized to compare with a declaration: type aliases mapped, index types
6
+ # downcased, missing nullability read as false. An empty Lua table may arrive as a map, hence Array().
7
+ # @api private
8
+ LiveSpace = Data.define(:engine, :format, :indexes) do
9
+ def self.from_eval(raw)
10
+ format = Array(raw['format']).map do |field|
11
+ FieldSpec.new(name: field.fetch('name').to_sym, type: Types.live(field.fetch('type', 'any')),
12
+ is_nullable: field.fetch('is_nullable', false))
13
+ end
14
+ indexes = Array(raw['indexes']).map { |index| LiveSpace::Index.from_eval(index, format) }
15
+
16
+ new(engine: raw.fetch('engine'), format:, indexes: indexes.sort_by(&:id))
17
+ end
18
+
19
+ def index(name) = indexes.find { |index| index.name == name }
20
+
21
+ def primary = indexes.find { |index| index.id.zero? }
22
+
23
+ def fields = format.to_h { |field| [field.name, field] }
24
+ end
25
+
26
+ LiveSpace::Index = Data.define(:id, :name, :type, :unique, :parts, :sequence) do
27
+ def self.from_eval(raw, format)
28
+ new(id: raw.fetch('id'), name: raw.fetch('name').to_sym, type: raw.fetch('type').downcase,
29
+ unique: raw.fetch('unique'), sequence: raw.fetch('sequence'),
30
+ parts: Array(raw['parts']).map { |part| part_from_eval(part, format) })
31
+ end
32
+
33
+ # fieldno is 1-based; a part beyond the format has no field name.
34
+ def self.part_from_eval(raw, format)
35
+ fieldno = raw.fetch('fieldno')
36
+
37
+ PartSpec.new(field: format[fieldno - 1]&.name || :"##{fieldno}", type: Types.live(raw.fetch('type')),
38
+ is_nullable: raw.fetch('is_nullable', false), path: raw['path'])
39
+ end
40
+ private_class_method :part_from_eval
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Declaration keys are checked against a closed list, so an unsupported Tarantool option such as is_sync
6
+ # fails loudly instead of looking applied.
7
+ # @api private
8
+ module Options
9
+ module_function
10
+
11
+ def check(given, allowed, context)
12
+ unknown = given.keys - allowed
13
+ return if unknown.empty?
14
+
15
+ raise ArgumentError, "#{context}: unsupported #{unknown.map(&:inspect).join(', ')}"
16
+ end
17
+
18
+ def boolean(value, context)
19
+ return value if [true, false].include?(value)
20
+
21
+ raise ArgumentError, "#{context}: expected true or false, got #{value.inspect}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # One part of an index key. Type and nullability left out stay nil until #inherit copies them from the
6
+ # field, as Tarantool does (verified for JSON path parts too). A path points inside the field, so its type
7
+ # cannot come from the field and must be declared.
8
+ # @api private
9
+ PartSpec = Data.define(:field, :type, :is_nullable, :path) do
10
+ def self.build(raw)
11
+ raw = { field: raw } unless raw.is_a?(Hash)
12
+ context = "index part #{raw[:field]}"
13
+ Options.check(raw, %i[field type is_nullable path], context)
14
+ raise ArgumentError, "#{context}: a part with path needs a type" if raw[:path] && !raw[:type]
15
+ raise ArgumentError, 'index part without field' if raw[:field].nil?
16
+
17
+ new(**attributes(raw, context))
18
+ end
19
+
20
+ def self.attributes(raw, context)
21
+ { field: raw[:field].to_sym,
22
+ type: raw[:type] && Types.declared(raw[:type], context),
23
+ is_nullable: raw[:is_nullable].nil? ? nil : Options.boolean(raw[:is_nullable], "#{context} is_nullable"),
24
+ path: raw[:path] && -raw[:path].to_s }
25
+ end
26
+
27
+ private_class_method :attributes
28
+
29
+ def inherit(field)
30
+ with(type: type || field.type, is_nullable: is_nullable.nil? ? field.is_nullable : is_nullable)
31
+ end
32
+
33
+ def to_lua = { 'field' => field.to_s, 'type' => type, 'is_nullable' => is_nullable, 'path' => path }.compact
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # The live state of everything a definition declares, compared with it, and what is left to create.
6
+ # @api private
7
+ class Plan
8
+ def initialize(spaces:, external:, client:)
9
+ @comparisons = spaces.values.map { |space| compare(client, space.name, space.indexes, space) } +
10
+ external.map { |name, indexes| compare(client, name, indexes) }
11
+ end
12
+
13
+ # complete: anything still missing is a mismatch too, used after apply has created it.
14
+ def verify(complete: false)
15
+ by_space = @comparisons.to_h do |comparison|
16
+ [comparison.name, comparison.mismatches + (complete ? still_missing(comparison) : [])]
17
+ end
18
+ by_space.reject! { |_, lines| lines.empty? }
19
+ raise MismatchError, by_space if by_space.any?
20
+
21
+ self
22
+ end
23
+
24
+ def creations
25
+ @comparisons.flat_map do |comparison|
26
+ space = comparison.missing_space? ? [Creation::Space.new(spec: comparison.space)] : []
27
+
28
+ space + comparison.missing_indexes.map { |index| Creation::Index.new(space: comparison.name, spec: index) }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def compare(client, name, indexes, space = nil)
35
+ Comparison.new(name:, indexes:, live: Inspector.call(client, name), space:)
36
+ end
37
+
38
+ def still_missing(comparison)
39
+ return ['missing after apply'] if comparison.missing_space?
40
+
41
+ comparison.missing_indexes.map { |index| "index #{index.name}: missing after apply" }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Rules Tarantool enforces on a whole space, checked on a resolved SpaceSpec before anything reaches the
6
+ # server.
7
+ # @api private
8
+ class SpaceRules
9
+ SEQUENCE_TYPES = %w[unsigned integer].freeze
10
+
11
+ def self.check(space) = new(space).check
12
+
13
+ def initialize(space)
14
+ @space = space
15
+ end
16
+
17
+ def check
18
+ raise error('at least one index is required') if @space.indexes.empty?
19
+
20
+ check_primary(@space.indexes.first)
21
+ check_no_secondary_sequence
22
+ check_engine
23
+ @space.indexes.each { |index| KeyRules.check(index, "space #{@space.name}") }
24
+
25
+ @space
26
+ end
27
+
28
+ private
29
+
30
+ def check_no_secondary_sequence
31
+ extra = @space.indexes.drop(1).find(&:sequence)
32
+ return unless extra
33
+
34
+ raise error("index #{extra.name}: sequence is only allowed on the primary index")
35
+ end
36
+
37
+ def check_primary(index)
38
+ check_primary_type(index)
39
+ check_primary_unique(index)
40
+ check_primary_nullable(index)
41
+ check_sequence(index) if index.sequence
42
+ end
43
+
44
+ def check_primary_type(index)
45
+ return unless IndexSpec::NON_UNIQUE.include?(index.type)
46
+
47
+ raise error("primary index #{index.name} can not be #{index.type}")
48
+ end
49
+
50
+ def check_primary_unique(index)
51
+ raise error("primary index #{index.name} must be unique") unless index.unique
52
+ end
53
+
54
+ def check_primary_nullable(index)
55
+ nullable = index.parts.find(&:is_nullable)
56
+ raise error("primary index #{index.name}: part #{nullable.field} can not be nullable") if nullable
57
+ end
58
+
59
+ def check_sequence(index)
60
+ return if index.parts.size == 1 && SEQUENCE_TYPES.include?(index.parts.first.type)
61
+
62
+ raise error("index #{index.name}: sequence needs a single unsigned or integer part")
63
+ end
64
+
65
+ def check_engine
66
+ return if @space.engine == 'memtx'
67
+
68
+ other = @space.indexes.reject { |index| index.type == 'tree' }
69
+ raise error("vinyl supports only tree indexes, got #{other.map(&:name).join(', ')}") if other.any?
70
+ end
71
+
72
+ def error(message) = ArgumentError.new("space #{@space.name}: #{message}")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # A declared space. Its index parts get the field types and nullability once #resolve runs.
6
+ # @api private
7
+ SpaceSpec = Data.define(:name, :engine, :format, :indexes) do
8
+ def self.build(name, engine:, format:, indexes:)
9
+ raise ArgumentError, 'space without name' if name.nil?
10
+
11
+ context = "space #{name}"
12
+ space = new(name: name.to_sym, engine: build_engine(engine, context), format: build_format(format, context),
13
+ indexes: [].freeze)
14
+
15
+ build_indexes(space, indexes, context)
16
+ end
17
+
18
+ def self.build_engine(engine, context)
19
+ engine = engine.to_s
20
+ raise ArgumentError, "#{context}: unknown engine #{engine.inspect}" unless SpaceSpec::ENGINES.include?(engine)
21
+
22
+ -engine
23
+ end
24
+
25
+ def self.build_indexes(space, indexes, context)
26
+ raise ArgumentError, "#{context}: indexes must be an Array" unless indexes.is_a?(Array)
27
+
28
+ indexes.reduce(space) { |result, raw| result.add_index(IndexSpec.build(*index_arguments(raw, context))) }
29
+ end
30
+
31
+ def self.build_format(raw, context)
32
+ raise ArgumentError, "#{context}: format must be an Array" unless raw.is_a?(Array)
33
+
34
+ fields = raw.map { |field| FieldSpec.build(field) }
35
+ duplicates = fields.map(&:name).tally.select { |_, count| count > 1 }.keys
36
+ raise ArgumentError, "#{context}: duplicate field #{duplicates.join(', ')}" if duplicates.any?
37
+
38
+ fields.freeze
39
+ end
40
+
41
+ def self.index_arguments(raw, context)
42
+ raise ArgumentError, "#{context}: index must be a Hash, got #{raw.inspect}" unless raw.is_a?(Hash)
43
+
44
+ [raw.fetch(:name) { raise ArgumentError, "#{context}: index without name" }, raw.except(:name)]
45
+ end
46
+ private_class_method :build_engine, :build_indexes, :build_format, :index_arguments
47
+
48
+ def add_index(index)
49
+ raise ArgumentError, "space #{name}: duplicate index #{index.name}" if indexes.any? { |i| i.name == index.name }
50
+
51
+ with(indexes: [*indexes, index].freeze)
52
+ end
53
+
54
+ def resolve
55
+ fields = format.to_h { |field| [field.name, field] }
56
+ unknown = unknown_field_errors(fields)
57
+ raise ArgumentError, "space #{name}: #{unknown.join('; ')}" if unknown.any?
58
+
59
+ with(indexes: indexes.map { |index| index.resolve(fields) }.freeze)
60
+ end
61
+
62
+ def to_h = { engine:, format: format.map(&:to_h), indexes: indexes.map(&:to_h) }
63
+
64
+ def to_lua = { 'engine' => engine, 'format' => format.map(&:to_lua) }
65
+
66
+ private
67
+
68
+ def unknown_field_errors(fields)
69
+ indexes.flat_map do |index|
70
+ index.unknown_fields(fields).map { |field| "index #{index.name}: unknown field #{field}" }
71
+ end
72
+ end
73
+ end
74
+
75
+ SpaceSpec::ENGINES = %w[memtx vinyl].freeze
76
+ end
77
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Field type names. The server keeps a space format type verbatim (str stays str) but normalizes index part
6
+ # types (str becomes string), so declarations accept only canonical names and live format types are mapped
7
+ # onto them before comparing.
8
+ # @api private
9
+ module Types
10
+ CANONICAL = %w[
11
+ any unsigned string integer number double boolean decimal uuid datetime interval varbinary scalar array map
12
+ ].freeze
13
+ ALIASES = { 'str' => 'string', 'num' => 'number' }.freeze
14
+
15
+ module_function
16
+
17
+ def declared(type, context)
18
+ name = type.to_s
19
+ return -name if CANONICAL.include?(name)
20
+
21
+ hint = canonical(name)
22
+ raise ArgumentError, "#{context}: unknown type #{name.inspect}#{", use #{hint}" if hint}"
23
+ end
24
+
25
+ def live(type)
26
+ name = type.to_s
27
+
28
+ canonical(name) || name
29
+ end
30
+
31
+ def canonical(name)
32
+ lower = name.downcase
33
+
34
+ ALIASES.fetch(lower) { lower if CANONICAL.include?(lower) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tarantool/client'
4
+
5
+ require_relative 'schema/version'
6
+ require_relative 'schema/errors'
7
+ require_relative 'schema/options'
8
+ require_relative 'schema/types'
9
+ require_relative 'schema/key_rules'
10
+ require_relative 'schema/field_spec'
11
+ require_relative 'schema/part_spec'
12
+ require_relative 'schema/index_spec'
13
+ require_relative 'schema/space_spec'
14
+ require_relative 'schema/space_rules'
15
+ require_relative 'schema/plan'
16
+ require_relative 'schema/definition'
17
+ require_relative 'schema/live_space'
18
+ require_relative 'schema/inspector'
19
+ require_relative 'schema/format_comparison'
20
+ require_relative 'schema/index_comparison'
21
+ require_relative 'schema/comparison'
22
+ require_relative 'schema/creation'
23
+
24
+ module Tarantool
25
+ # Idempotent declaration of spaces, indexes and primary key sequences, applied over an admin connection.
26
+ module Schema
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tarantool-schema
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Starlexxx
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tarantool-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
27
+ description: Declare Tarantool 2.11+ and 3.x spaces, indexes and primary key sequences
28
+ in Ruby; apply creates what is missing and fails with a readable diff when the live
29
+ schema differs.
30
+ email:
31
+ - apavlovskiy088@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE
38
+ - README.md
39
+ - lib/tarantool/schema.rb
40
+ - lib/tarantool/schema/comparison.rb
41
+ - lib/tarantool/schema/creation.rb
42
+ - lib/tarantool/schema/definition.rb
43
+ - lib/tarantool/schema/errors.rb
44
+ - lib/tarantool/schema/field_spec.rb
45
+ - lib/tarantool/schema/format_comparison.rb
46
+ - lib/tarantool/schema/index_comparison.rb
47
+ - lib/tarantool/schema/index_spec.rb
48
+ - lib/tarantool/schema/inspector.rb
49
+ - lib/tarantool/schema/key_rules.rb
50
+ - lib/tarantool/schema/live_space.rb
51
+ - lib/tarantool/schema/options.rb
52
+ - lib/tarantool/schema/part_spec.rb
53
+ - lib/tarantool/schema/plan.rb
54
+ - lib/tarantool/schema/space_rules.rb
55
+ - lib/tarantool/schema/space_spec.rb
56
+ - lib/tarantool/schema/types.rb
57
+ - lib/tarantool/schema/version.rb
58
+ homepage: https://github.com/Starlexxx/tarantool-ruby
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/Starlexxx/tarantool-ruby/tree/master/tarantool-schema
63
+ changelog_uri: https://github.com/Starlexxx/tarantool-ruby/blob/master/tarantool-schema/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/Starlexxx/tarantool-ruby/issues
65
+ homepage_uri: https://github.com/Starlexxx/tarantool-ruby
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.2'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.22
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Idempotent Tarantool schema declaration for deploys
86
+ test_files: []