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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE +21 -0
  4. data/README.md +131 -0
  5. data/docs/benchmarks.md +135 -0
  6. data/docs/core.md +123 -0
  7. data/docs/portable.md +99 -0
  8. data/docs/releasing.md +37 -0
  9. data/docs/ruby-profile-v1.md +226 -0
  10. data/docs/ruby.md +61 -0
  11. data/examples/indexed_users.rb +23 -0
  12. data/examples/portable.rb +62 -0
  13. data/examples/ruby.rb +56 -0
  14. data/lib/xitdb/context.rb +360 -0
  15. data/lib/xitdb/conversion.rb +222 -0
  16. data/lib/xitdb/core/array_list.rb +282 -0
  17. data/lib/xitdb/core/b_tree.rb +546 -0
  18. data/lib/xitdb/core/bytes.rb +181 -0
  19. data/lib/xitdb/core/compaction.rb +172 -0
  20. data/lib/xitdb/core/crc64.rb +22 -0
  21. data/lib/xitdb/core/cursor.rb +201 -0
  22. data/lib/xitdb/core/database.rb +359 -0
  23. data/lib/xitdb/core/file_store.rb +194 -0
  24. data/lib/xitdb/core/format.rb +148 -0
  25. data/lib/xitdb/core/hash_map.rb +371 -0
  26. data/lib/xitdb/core/hasher.rb +49 -0
  27. data/lib/xitdb/core/linked_array_list.rb +85 -0
  28. data/lib/xitdb/core/memory_store.rb +277 -0
  29. data/lib/xitdb/core/offset_map.rb +85 -0
  30. data/lib/xitdb/core/replication.rb +242 -0
  31. data/lib/xitdb/core/sorted_map.rb +361 -0
  32. data/lib/xitdb/core/transaction.rb +159 -0
  33. data/lib/xitdb/core.rb +17 -0
  34. data/lib/xitdb/database.rb +332 -0
  35. data/lib/xitdb/declarations.rb +73 -0
  36. data/lib/xitdb/errors.rb +84 -0
  37. data/lib/xitdb/history.rb +58 -0
  38. data/lib/xitdb/input_guard.rb +39 -0
  39. data/lib/xitdb/profiles/portable.rb +407 -0
  40. data/lib/xitdb/profiles/ruby.rb +173 -0
  41. data/lib/xitdb/revision.rb +42 -0
  42. data/lib/xitdb/ruby/codec.rb +123 -0
  43. data/lib/xitdb/ruby/containers.rb +495 -0
  44. data/lib/xitdb/ruby/key_encoding.rb +60 -0
  45. data/lib/xitdb/ruby/ordered_keys.rb +87 -0
  46. data/lib/xitdb/ruby/scalars.rb +296 -0
  47. data/lib/xitdb/ruby/wire.rb +146 -0
  48. data/lib/xitdb/ruby.rb +46 -0
  49. data/lib/xitdb/snapshot.rb +13 -0
  50. data/lib/xitdb/streams.rb +148 -0
  51. data/lib/xitdb/value_walker.rb +192 -0
  52. data/lib/xitdb/version.rb +5 -0
  53. data/lib/xitdb/views/array.rb +303 -0
  54. data/lib/xitdb/views/base.rb +271 -0
  55. data/lib/xitdb/views/linked_array.rb +18 -0
  56. data/lib/xitdb/views/map.rb +162 -0
  57. data/lib/xitdb/views/set.rb +41 -0
  58. data/lib/xitdb/views/sorted.rb +77 -0
  59. data/lib/xitdb.rb +20 -0
  60. data/sig/base.rbs +76 -0
  61. data/sig/core.rbs +697 -0
  62. data/sig/manifest.yaml +10 -0
  63. data/sig/native.rbs +118 -0
  64. data/sig/public.rbs +427 -0
  65. data/sig/ruby.rbs +337 -0
  66. 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
@@ -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