xitdb 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/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/docs/benchmarks.md +135 -0
- data/docs/core.md +123 -0
- data/docs/portable.md +99 -0
- data/docs/releasing.md +37 -0
- data/docs/ruby-profile-v1.md +226 -0
- data/docs/ruby.md +61 -0
- data/examples/indexed_users.rb +23 -0
- data/examples/portable.rb +62 -0
- data/examples/ruby.rb +56 -0
- data/lib/xitdb/context.rb +360 -0
- data/lib/xitdb/conversion.rb +222 -0
- data/lib/xitdb/core/array_list.rb +282 -0
- data/lib/xitdb/core/b_tree.rb +546 -0
- data/lib/xitdb/core/bytes.rb +181 -0
- data/lib/xitdb/core/compaction.rb +172 -0
- data/lib/xitdb/core/crc64.rb +22 -0
- data/lib/xitdb/core/cursor.rb +201 -0
- data/lib/xitdb/core/database.rb +359 -0
- data/lib/xitdb/core/file_store.rb +194 -0
- data/lib/xitdb/core/format.rb +148 -0
- data/lib/xitdb/core/hash_map.rb +371 -0
- data/lib/xitdb/core/hasher.rb +49 -0
- data/lib/xitdb/core/linked_array_list.rb +85 -0
- data/lib/xitdb/core/memory_store.rb +277 -0
- data/lib/xitdb/core/offset_map.rb +85 -0
- data/lib/xitdb/core/replication.rb +242 -0
- data/lib/xitdb/core/sorted_map.rb +361 -0
- data/lib/xitdb/core/transaction.rb +159 -0
- data/lib/xitdb/core.rb +17 -0
- data/lib/xitdb/database.rb +332 -0
- data/lib/xitdb/declarations.rb +73 -0
- data/lib/xitdb/errors.rb +84 -0
- data/lib/xitdb/history.rb +58 -0
- data/lib/xitdb/input_guard.rb +39 -0
- data/lib/xitdb/profiles/portable.rb +407 -0
- data/lib/xitdb/profiles/ruby.rb +173 -0
- data/lib/xitdb/revision.rb +42 -0
- data/lib/xitdb/ruby/codec.rb +123 -0
- data/lib/xitdb/ruby/containers.rb +495 -0
- data/lib/xitdb/ruby/key_encoding.rb +60 -0
- data/lib/xitdb/ruby/ordered_keys.rb +87 -0
- data/lib/xitdb/ruby/scalars.rb +296 -0
- data/lib/xitdb/ruby/wire.rb +146 -0
- data/lib/xitdb/ruby.rb +46 -0
- data/lib/xitdb/snapshot.rb +13 -0
- data/lib/xitdb/streams.rb +148 -0
- data/lib/xitdb/value_walker.rb +192 -0
- data/lib/xitdb/version.rb +5 -0
- data/lib/xitdb/views/array.rb +303 -0
- data/lib/xitdb/views/base.rb +271 -0
- data/lib/xitdb/views/linked_array.rb +18 -0
- data/lib/xitdb/views/map.rb +162 -0
- data/lib/xitdb/views/set.rb +41 -0
- data/lib/xitdb/views/sorted.rb +77 -0
- data/lib/xitdb.rb +20 -0
- data/sig/base.rbs +76 -0
- data/sig/core.rbs +697 -0
- data/sig/manifest.yaml +10 -0
- data/sig/native.rbs +118 -0
- data/sig/public.rbs +427 -0
- data/sig/ruby.rbs +337 -0
- metadata +137 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "conversion"
|
|
4
|
+
|
|
5
|
+
# Persistent collections with immutable snapshots and explicit transactions.
|
|
6
|
+
module XitDB
|
|
7
|
+
# Owns the profile, history, and transactions of a public database.
|
|
8
|
+
class Database
|
|
9
|
+
attr_reader :core, :profile
|
|
10
|
+
|
|
11
|
+
def self.hasher(value)
|
|
12
|
+
if value.is_a?(Core::Hasher)
|
|
13
|
+
raise ProfileMismatchError, "rb01 is reserved for XitDB::Ruby" if value.id == "rb01"
|
|
14
|
+
|
|
15
|
+
return value
|
|
16
|
+
end
|
|
17
|
+
case value
|
|
18
|
+
when :sha1 then Core::Hasher.sha1
|
|
19
|
+
when :sha256 then Core::Hasher.sha256
|
|
20
|
+
when :sha384 then Core::Hasher.sha384
|
|
21
|
+
when :sha512 then Core::Hasher.sha512
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "hash must be a builtin algorithm or Core::Hasher"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.open(path, readonly: false, hash: nil, lock_timeout: 5.0)
|
|
28
|
+
selected = hasher(hash) unless hash.nil?
|
|
29
|
+
# @type var options: { ?hash_id: String, ?hash_size: Integer }
|
|
30
|
+
options = selected ? { hash_id: selected.id, hash_size: selected.size } : {}
|
|
31
|
+
store, created = open_store(path, readonly: readonly)
|
|
32
|
+
begin
|
|
33
|
+
core = Core::Database.new(store: store, initialize_empty: created, lock_timeout: lock_timeout, **options)
|
|
34
|
+
raise ProfileMismatchError, "Ruby profile file requires XitDB::Ruby.open" if core.header.hash_id == "rb01"
|
|
35
|
+
|
|
36
|
+
selected ||= header_hasher(core.header)
|
|
37
|
+
database = new(core: core, profile: Profiles::Portable.new(hasher: selected), readonly: readonly)
|
|
38
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Close owned storage on Interrupt and SystemExit.
|
|
39
|
+
store.send(:close_unreturned)
|
|
40
|
+
raise
|
|
41
|
+
end
|
|
42
|
+
return database unless block_given?
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
yield database
|
|
46
|
+
ensure
|
|
47
|
+
database.close
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.header_hasher(header)
|
|
52
|
+
case [header.hash_id, header.hash_size]
|
|
53
|
+
when ["sha1", 20] then Core::Hasher.sha1
|
|
54
|
+
when ["sha2", 32] then Core::Hasher.sha256
|
|
55
|
+
when ["sha2", 48] then Core::Hasher.sha384
|
|
56
|
+
when ["sha2", 64] then Core::Hasher.sha512
|
|
57
|
+
else raise ProfileMismatchError, "unknown hash identifier or size; supply an explicit hash configuration"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
private_class_method :header_hasher
|
|
61
|
+
|
|
62
|
+
def self.open_store(path, readonly:)
|
|
63
|
+
return [Core::FileStore.open(path, readonly: true), false] if readonly
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
[Core::FileStore.open(path, exclusive: true), true]
|
|
67
|
+
rescue Errno::EEXIST
|
|
68
|
+
[Core::FileStore.open(path, create: false), false]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :open_store
|
|
72
|
+
|
|
73
|
+
def self.memory(hash: :sha1)
|
|
74
|
+
selected = hasher(hash)
|
|
75
|
+
database = new(core: Core::Database.memory(hash_id: selected.id, hash_size: selected.size),
|
|
76
|
+
profile: Profiles::Portable.new(hasher: selected))
|
|
77
|
+
return database unless block_given?
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
yield database
|
|
81
|
+
ensure
|
|
82
|
+
database.close
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(core:, profile:, readonly: false)
|
|
87
|
+
@core = core
|
|
88
|
+
@profile = profile
|
|
89
|
+
@readonly = readonly
|
|
90
|
+
snapshot
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def transaction(&block)
|
|
94
|
+
raise ArgumentError, "transaction requires a block" unless block_given?
|
|
95
|
+
raise ReadOnlyError, "database is read-only" if @readonly
|
|
96
|
+
|
|
97
|
+
core.with_operation { perform_transaction(&block) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def perform_transaction
|
|
101
|
+
committed = core.transaction do |history_cursor|
|
|
102
|
+
array = history_array(history_cursor)
|
|
103
|
+
empty = array.size.zero? # rubocop:disable Style/ZeroLengthPredicate -- Core arrays expose size, not empty?.
|
|
104
|
+
slot = empty ? Core::Slot.new : (array.get(-1) || raise(CorruptionError, "missing history root"))
|
|
105
|
+
context = Context.new(database: self, root: slot, bound: history_cursor.bound,
|
|
106
|
+
transaction: history_cursor.transaction)
|
|
107
|
+
validate_root(slot, context) unless empty
|
|
108
|
+
root = context.view(slot, path: [], kind: :map)
|
|
109
|
+
yield root
|
|
110
|
+
if context.changed?
|
|
111
|
+
array.append(context.root)
|
|
112
|
+
else
|
|
113
|
+
context.writable_transaction.cancel_publication
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
snapshot_from_cursor(committed)
|
|
117
|
+
end
|
|
118
|
+
private :perform_transaction
|
|
119
|
+
|
|
120
|
+
def snapshot(revision: nil)
|
|
121
|
+
core.with_operation do
|
|
122
|
+
if revision
|
|
123
|
+
raise TypeError, "revision must be an XitDB::Revision" unless revision.is_a?(Revision)
|
|
124
|
+
|
|
125
|
+
return revision.resolve(self)
|
|
126
|
+
end
|
|
127
|
+
snapshot_from_cursor(core.root_cursor)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def history
|
|
132
|
+
core.check_open!
|
|
133
|
+
History.new(self)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def revert(value)
|
|
137
|
+
core.with_operation { perform_revert(value) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def perform_revert(value)
|
|
141
|
+
target = if value.is_a?(Snapshot)
|
|
142
|
+
unless value.context.database.core.header.file_id == core.header.file_id
|
|
143
|
+
raise ForeignRevisionError, "snapshot belongs to another database"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
value.revision ? snapshot(revision: value.revision) : value
|
|
147
|
+
elsif value.is_a?(Revision)
|
|
148
|
+
snapshot(revision: value)
|
|
149
|
+
else
|
|
150
|
+
raise TypeError, "revert requires a Snapshot or Revision"
|
|
151
|
+
end
|
|
152
|
+
raise ReadOnlyError, "database is read-only" if @readonly
|
|
153
|
+
|
|
154
|
+
committed = core.transaction do |cursor|
|
|
155
|
+
# Validate again under the writer lock in case the file was restored.
|
|
156
|
+
selected = target.revision ? snapshot(revision: target.revision) : target
|
|
157
|
+
slot = selected.slot
|
|
158
|
+
if slot.empty?
|
|
159
|
+
context = Context.new(database: self, root: slot, bound: cursor.bound, transaction: cursor.transaction)
|
|
160
|
+
slot = profile.encode({}, context: context, path: [])
|
|
161
|
+
end
|
|
162
|
+
history_array(cursor).append(slot)
|
|
163
|
+
end
|
|
164
|
+
snapshot_from_cursor(committed)
|
|
165
|
+
end
|
|
166
|
+
private :perform_revert
|
|
167
|
+
|
|
168
|
+
def close
|
|
169
|
+
core.close
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def compact(to:)
|
|
173
|
+
compacted = core.compact(to: to)
|
|
174
|
+
begin
|
|
175
|
+
selected_profile = profile
|
|
176
|
+
copied_profile = if defined?(Profiles::Ruby) && selected_profile.is_a?(Profiles::Ruby)
|
|
177
|
+
Profiles::Ruby.new(codecs: selected_profile.codecs)
|
|
178
|
+
else
|
|
179
|
+
Profiles::Portable.new(hasher: profile.hasher)
|
|
180
|
+
end
|
|
181
|
+
self.class.new(core: compacted, profile: copied_profile)
|
|
182
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Close owned storage on Interrupt and SystemExit.
|
|
183
|
+
compacted.send(:close_unreturned)
|
|
184
|
+
raise
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def closed?
|
|
189
|
+
core.closed?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def convert(to:, profile:, history: :all, transform: nil)
|
|
193
|
+
raise ArgumentError, "history must be :all or :latest" unless %i[all latest].include?(history)
|
|
194
|
+
raise TypeError, "transform must respond to call" unless transform.nil? || transform.respond_to?(:call)
|
|
195
|
+
|
|
196
|
+
require_relative "ruby"
|
|
197
|
+
target_profile = conversion_profile(profile)
|
|
198
|
+
core.with_operation do
|
|
199
|
+
captured = core.root_cursor
|
|
200
|
+
histories = history_array(captured)
|
|
201
|
+
store = Core::FileStore.open(to)
|
|
202
|
+
completed = published = false
|
|
203
|
+
# @type var target_core: Core::Database?
|
|
204
|
+
target_core = nil
|
|
205
|
+
begin
|
|
206
|
+
raise ArgumentError, "conversion target is the source file" if store.storage_identity == core.storage_identity
|
|
207
|
+
|
|
208
|
+
created_core = Core::Database.new(store: store, hash_id: target_profile.hasher.id,
|
|
209
|
+
hash_size: target_profile.hasher.size, require_empty: true)
|
|
210
|
+
target_core = created_core
|
|
211
|
+
target = Database.new(core: created_core, profile: target_profile)
|
|
212
|
+
copy_history(target, store, captured, histories, history, transform)
|
|
213
|
+
published = true
|
|
214
|
+
completed = true
|
|
215
|
+
target
|
|
216
|
+
ensure
|
|
217
|
+
unless completed
|
|
218
|
+
target_core&.send(:restore_empty_target) unless published
|
|
219
|
+
store.send(:close_unreturned)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Conventional history is shared by profiles; logical roots use the profile seam.
|
|
226
|
+
def history_array(cursor)
|
|
227
|
+
unless cursor.slot.empty? || cursor.slot.tag == :array_list
|
|
228
|
+
raise ProfileMismatchError, "public databases require a top-level history array"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
Core::ArrayList.new(cursor)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def snapshot_from_history(cursor, index, revision: nil)
|
|
235
|
+
core.with_operation do
|
|
236
|
+
slot = history_array(cursor).get(index)
|
|
237
|
+
raise IndexError, "history index outside history" unless slot
|
|
238
|
+
raise ProfileMismatchError, "history entry must contain a root map" if slot.empty?
|
|
239
|
+
|
|
240
|
+
context = Context.new(database: self, root: slot, bound: cursor.bound)
|
|
241
|
+
validate_root(slot, context)
|
|
242
|
+
revision ||= revision_for(cursor, index)
|
|
243
|
+
Snapshot.new(context: context, revision: revision)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def conversion_profile(selected)
|
|
250
|
+
source_profile = profile
|
|
251
|
+
case selected
|
|
252
|
+
when :portable
|
|
253
|
+
Profiles::Portable.new(hasher: source_profile.instance_of?(Profiles::Portable) ? source_profile.hasher : Core::Hasher.sha1)
|
|
254
|
+
when :ruby
|
|
255
|
+
Profiles::Ruby.new(codecs: source_profile.is_a?(Profiles::Ruby) ? source_profile.codecs : [])
|
|
256
|
+
else
|
|
257
|
+
if selected.instance_of?(Profiles::Portable)
|
|
258
|
+
Profiles::Portable.new(hasher: selected.hasher)
|
|
259
|
+
elsif selected.instance_of?(Profiles::Ruby)
|
|
260
|
+
Profiles::Ruby.new(codecs: selected.codecs)
|
|
261
|
+
else
|
|
262
|
+
raise ArgumentError, "profile must be :portable, :ruby, or a supported profile instance"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def copy_history(target, store, captured, histories, history, transform)
|
|
268
|
+
target_core = target.core
|
|
269
|
+
target_core.transaction do |cursor|
|
|
270
|
+
current_header = Core::Header.parse(store.read_at(0, Core::Header::SIZE))
|
|
271
|
+
unchanged = current_header.file_id == target_core.header.file_id && cursor.bound == Core::Database::MINIMUM_SIZE
|
|
272
|
+
raise ArgumentError, "conversion target changed before copying" unless unchanged && cursor.slot.empty?
|
|
273
|
+
|
|
274
|
+
if histories.size.zero? # rubocop:disable Style/ZeroLengthPredicate -- Core arrays expose size, not empty?.
|
|
275
|
+
transaction = cursor.transaction || raise(ReadOnlyError, "conversion requires a transaction")
|
|
276
|
+
transaction.cancel_publication
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
output = Core::ArrayList.new(cursor)
|
|
280
|
+
first = history == :latest ? histories.size - 1 : 0
|
|
281
|
+
(first...histories.size).each do |index|
|
|
282
|
+
source = snapshot_from_history(captured, index).context
|
|
283
|
+
destination = Context.new(database: target, root: Core::Slot.new,
|
|
284
|
+
bound: cursor.bound, transaction: cursor.transaction)
|
|
285
|
+
transaction = destination.writable_transaction
|
|
286
|
+
transaction.with_logical_mutation do
|
|
287
|
+
output.append(Conversion.new(source, destination, transform: transform).copy(source.root))
|
|
288
|
+
transaction.freeze
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate_root(slot, context)
|
|
295
|
+
adapter = profile.container(slot, context: context, path: []) unless slot.empty?
|
|
296
|
+
return if adapter && %i[map sorted_map].include?(adapter.kind)
|
|
297
|
+
|
|
298
|
+
raise ProfileMismatchError, "history entry must contain a root map"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def snapshot_from_cursor(cursor)
|
|
302
|
+
count = history_array(cursor).size
|
|
303
|
+
return snapshot_from_history(cursor, count - 1) unless count.zero?
|
|
304
|
+
|
|
305
|
+
Snapshot.new(context: Context.new(database: self, root: Core::Slot.new, bound: cursor.bound), revision: nil)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def revision_for(cursor, index)
|
|
309
|
+
position = cursor.bound - Core::Database::TRAILER_SIZE
|
|
310
|
+
checksum = core.read_at(position + 17, 8, bound: cursor.bound).unpack1("Q>")
|
|
311
|
+
raise CorruptionError, "invalid history checksum" unless checksum.is_a?(Integer)
|
|
312
|
+
|
|
313
|
+
Revision.new(file_id: core.header.file_id, trailer_position: position, checksum: checksum, index: index)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def self.open(path, readonly: false, hash: nil, lock_timeout: 5.0, &block)
|
|
318
|
+
if block
|
|
319
|
+
Database.open(path, readonly: readonly, hash: hash, lock_timeout: lock_timeout, &block)
|
|
320
|
+
else
|
|
321
|
+
Database.open(path, readonly: readonly, hash: hash, lock_timeout: lock_timeout)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def self.memory(hash: :sha1, &block)
|
|
326
|
+
if block
|
|
327
|
+
Database.memory(hash: hash, &block)
|
|
328
|
+
else
|
|
329
|
+
Database.memory(hash: hash)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Persistent collections with immutable snapshots and explicit transactions.
|
|
4
|
+
module XitDB
|
|
5
|
+
# Explicit unsigned storage for an integer.
|
|
6
|
+
class UInt
|
|
7
|
+
attr_reader :value
|
|
8
|
+
|
|
9
|
+
def initialize(value)
|
|
10
|
+
@value = Core::Format.integer(value, 0..Core::Format::UINT64_MAX)
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Describes the storage layout of a new empty collection.
|
|
16
|
+
class CollectionDeclaration
|
|
17
|
+
KINDS = %i[hash_map counted_hash_map hash_set counted_hash_set array_list linked_array_list sorted_map
|
|
18
|
+
sorted_set].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :kind, :key_kind
|
|
21
|
+
|
|
22
|
+
def initialize(kind, key: nil)
|
|
23
|
+
raise ArgumentError, "unknown collection kind" unless KINDS.include?(kind)
|
|
24
|
+
if !nil.equal?(key) && (!%i[sorted_map sorted_set].include?(kind) || !%i[string integer float time].include?(key))
|
|
25
|
+
raise ArgumentError, "sorted key kind must be :string, :integer, :float, or :time"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@kind = kind
|
|
29
|
+
@key_kind = key
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def counted?
|
|
34
|
+
%i[counted_hash_map counted_hash_set].include?(kind)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.uint(value)
|
|
39
|
+
UInt.new(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.bytes(bytes, format_tag: nil)
|
|
43
|
+
Core::ByteValue.new(bytes, format_tag: format_tag)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.map(counted: false)
|
|
47
|
+
raise ArgumentError, "counted must be true or false" unless counted.equal?(true) || counted.equal?(false)
|
|
48
|
+
|
|
49
|
+
CollectionDeclaration.new(counted ? :counted_hash_map : :hash_map)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.set(counted: false)
|
|
53
|
+
raise ArgumentError, "counted must be true or false" unless counted.equal?(true) || counted.equal?(false)
|
|
54
|
+
|
|
55
|
+
CollectionDeclaration.new(counted ? :counted_hash_set : :hash_set)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.array
|
|
59
|
+
CollectionDeclaration.new(:array_list)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.linked_array
|
|
63
|
+
CollectionDeclaration.new(:linked_array_list)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.sorted_map(key: nil)
|
|
67
|
+
CollectionDeclaration.new(:sorted_map, key: key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.sorted_set(key: nil)
|
|
71
|
+
CollectionDeclaration.new(:sorted_set, key: key)
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/xitdb/errors.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module XitDB
|
|
4
|
+
# Base class for database errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
# An unsupported on-disk format version.
|
|
7
|
+
class FormatVersionError < Error; end
|
|
8
|
+
# Invalid structural data in the database file.
|
|
9
|
+
class CorruptionError < Error; end
|
|
10
|
+
# A database opened with an incompatible value profile.
|
|
11
|
+
class ProfileMismatchError < Error; end
|
|
12
|
+
# An operation outside the allowed transaction state.
|
|
13
|
+
class TransactionStateError < Error; end
|
|
14
|
+
# An operation on a closed database.
|
|
15
|
+
class ClosedError < Error; end
|
|
16
|
+
# A view whose transaction or location has expired.
|
|
17
|
+
class ExpiredViewError < Error; end
|
|
18
|
+
# A revision from another database.
|
|
19
|
+
class ForeignRevisionError < Error; end
|
|
20
|
+
# A mutation through a read-only handle.
|
|
21
|
+
class ReadOnlyError < Error; end
|
|
22
|
+
# A mutation that conflicts with an active iterator or stream.
|
|
23
|
+
class ConcurrentModificationError < Error; end
|
|
24
|
+
# A database lock that could not be acquired.
|
|
25
|
+
class BusyError < Error; end
|
|
26
|
+
# A custom value codec failed.
|
|
27
|
+
class CodecError < Error; end
|
|
28
|
+
# Invalid or conflicting codec registrations.
|
|
29
|
+
class CodecConfigurationError < CodecError; end
|
|
30
|
+
|
|
31
|
+
# An invalid value with an owned, immutable diagnostic path.
|
|
32
|
+
class ValueError < Error
|
|
33
|
+
attr_reader :path
|
|
34
|
+
|
|
35
|
+
def initialize(message = nil, path: [])
|
|
36
|
+
@path = copy_path(path)
|
|
37
|
+
super(message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def copy_path(value)
|
|
43
|
+
case value
|
|
44
|
+
when Array then Array.instance_method(:map).bind_call(value) { |part| copy_path(part) }.freeze
|
|
45
|
+
when Hash then Hash.instance_method(:to_h).bind_call(value) do |key, part|
|
|
46
|
+
[copy_path(key), copy_path(part)]
|
|
47
|
+
end.freeze
|
|
48
|
+
when String then String.new(value).freeze
|
|
49
|
+
when NilClass, TrueClass, FalseClass, Integer, Float, Symbol then value
|
|
50
|
+
else
|
|
51
|
+
klass = Object.instance_method(:class).bind_call(value)
|
|
52
|
+
name = Module.instance_method(:name).bind_call(klass) || "anonymous class"
|
|
53
|
+
"#<unsupported #{name}>".freeze
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A malformed encoded value.
|
|
59
|
+
class InvalidValueError < ValueError; end
|
|
60
|
+
# A value that the selected profile cannot store.
|
|
61
|
+
class UnsupportedValueError < ValueError; end
|
|
62
|
+
# An unsupported scalar encoding or version.
|
|
63
|
+
class UnsupportedEncodingError < ValueError; end
|
|
64
|
+
# A recursive input that cannot be stored as a value.
|
|
65
|
+
class CyclicValueError < ValueError; end
|
|
66
|
+
# A view or value owned by another database.
|
|
67
|
+
class ForeignValueError < ValueError; end
|
|
68
|
+
# Distinct keys with identical encoded representations.
|
|
69
|
+
class KeyCollisionError < ValueError; end
|
|
70
|
+
|
|
71
|
+
# A failed commit with its observed durability outcome.
|
|
72
|
+
class CommitError < Error
|
|
73
|
+
attr_reader :outcome
|
|
74
|
+
|
|
75
|
+
def initialize(message = nil, outcome:)
|
|
76
|
+
unless %i[not_committed committed unknown].include?(outcome)
|
|
77
|
+
raise ArgumentError, "invalid commit outcome: #{outcome.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@outcome = outcome
|
|
81
|
+
super(message)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module XitDB
|
|
4
|
+
# Enumerates the committed snapshots of a database.
|
|
5
|
+
class History
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(database)
|
|
9
|
+
@database = database
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](index)
|
|
13
|
+
@database.core.with_operation do
|
|
14
|
+
cursor = @database.core.root_cursor
|
|
15
|
+
count = @database.history_array(cursor).size
|
|
16
|
+
raise TypeError, "history index must be an Integer" unless index.is_a?(Integer)
|
|
17
|
+
|
|
18
|
+
index += count if index.negative?
|
|
19
|
+
return nil unless index >= 0 && index < count
|
|
20
|
+
|
|
21
|
+
@database.snapshot_from_history(cursor, index)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(index)
|
|
26
|
+
self[index] || raise(IndexError, "history index outside history")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def size
|
|
30
|
+
@database.core.with_operation { @database.history_array(@database.core.root_cursor).size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each
|
|
34
|
+
return enum_for(:each) unless block_given?
|
|
35
|
+
|
|
36
|
+
# @type var cursor: Core::Cursor?
|
|
37
|
+
cursor = nil
|
|
38
|
+
count = 0
|
|
39
|
+
index = 0
|
|
40
|
+
while cursor.nil? || index < count
|
|
41
|
+
snapshot = @database.core.with_operation do
|
|
42
|
+
captured = cursor
|
|
43
|
+
unless captured
|
|
44
|
+
captured = @database.core.root_cursor
|
|
45
|
+
cursor = captured
|
|
46
|
+
count = @database.history_array(captured).size
|
|
47
|
+
end
|
|
48
|
+
@database.snapshot_from_history(captured, index) if index < count
|
|
49
|
+
end
|
|
50
|
+
break unless snapshot
|
|
51
|
+
|
|
52
|
+
yield snapshot
|
|
53
|
+
index += 1
|
|
54
|
+
end
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module XitDB
|
|
6
|
+
# Rejects objects with altered method behavior before encoding them.
|
|
7
|
+
module InputGuard
|
|
8
|
+
def self.check!(value, path: [])
|
|
9
|
+
unless Object.instance_method(:is_a?).bind_call(value, Object)
|
|
10
|
+
raise UnsupportedValueError.new("unsupported Ruby object", path: path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
unless Object.instance_method(:singleton_methods).bind_call(value).empty?
|
|
14
|
+
raise UnsupportedValueError.new("singleton behavior is unsupported", path: path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
klass = Object.instance_method(:class).bind_call(value)
|
|
18
|
+
# @type var methods: ::Array[native_visibility_pair]
|
|
19
|
+
methods = [%i[public_instance_methods public_methods], %i[protected_instance_methods protected_methods],
|
|
20
|
+
%i[private_instance_methods private_methods]]
|
|
21
|
+
methods.each do |instance_methods, object_methods|
|
|
22
|
+
expected = Module.instance_method(instance_methods).bind_call(klass)
|
|
23
|
+
actual = Object.instance_method(object_methods).bind_call(value)
|
|
24
|
+
unless (expected - actual).empty? && (actual - expected).empty?
|
|
25
|
+
raise UnsupportedValueError.new("singleton behavior or method removal is unsupported", path: path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
# Private singleton overrides are absent from #singleton_methods. Inspect
|
|
29
|
+
# their owners without creating a singleton class on chilled Strings.
|
|
30
|
+
Object.instance_method(:private_methods).bind_call(value, false).each do |name|
|
|
31
|
+
owner = Object.instance_method(:method).bind_call(value, name).owner
|
|
32
|
+
expected = Module.instance_method(:instance_method).bind_call(klass, name).owner
|
|
33
|
+
unless BasicObject.instance_method(:equal?).bind_call(owner, expected)
|
|
34
|
+
raise UnsupportedValueError.new("singleton behavior is unsupported", path: path)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|