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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module XitDB
6
+ module Streams
7
+ # Restricts stream access to the lifetime of its owning block.
8
+ class Handle
9
+ def initialize(context)
10
+ @context = context
11
+ @active = true
12
+ end
13
+
14
+ def expire!
15
+ @active = false
16
+ end
17
+
18
+ private
19
+
20
+ def check!
21
+ raise ExpiredViewError, "byte stream block has ended" unless @active
22
+
23
+ @context.check!
24
+ end
25
+
26
+ def integer(value)
27
+ result = Integer.try_convert(value) || raise(TypeError, "expected an Integer")
28
+ limit = (1 << ((0.size * 8) - 1)) - 1
29
+ raise RangeError, "integer too big to convert into long" unless result.between?(-limit - 1, limit)
30
+
31
+ result
32
+ end
33
+ end
34
+
35
+ # Reads a captured byte value with bounded storage reads.
36
+ class Reader < Handle
37
+ def initialize(context, slot)
38
+ super(context)
39
+ unless %i[bytes short_bytes].include?(slot.tag) && !slot.full
40
+ raise InvalidValueError, "stream requires untagged bytes"
41
+ end
42
+
43
+ @source = Core::Bytes.open(context.database.core, slot, bound: context.read_bound)
44
+ @size = @source.size
45
+ @pos = 0
46
+ end
47
+
48
+ def read(length = nil, output = nil)
49
+ check!
50
+ @context.read do
51
+ check!
52
+ count = length.nil? ? [@size - @pos, 0].max : integer(length)
53
+ raise ArgumentError, "negative length" if count.negative?
54
+
55
+ buffer = if output.nil?
56
+ +"".b
57
+ else
58
+ String.try_convert(output) || raise(TypeError, "expected a String buffer")
59
+ end
60
+ buffer.replace("".b)
61
+ return nil if !length.nil? && count.positive? && @pos >= @size
62
+
63
+ remaining = count.clamp(0, [@size - @pos, 0].max)
64
+ while remaining.positive?
65
+ size = [remaining, Core::Bytes::CHUNK_SIZE].min
66
+ buffer << @source.read(@pos, size)
67
+ @pos += size
68
+ remaining -= size
69
+ end
70
+ buffer
71
+ end
72
+ end
73
+
74
+ def readpartial(length, output = nil)
75
+ check!
76
+ read(integer(length), output) || raise(EOFError, "end of byte stream")
77
+ end
78
+
79
+ def seek(offset, whence = IO::SEEK_SET)
80
+ check!
81
+ offset = integer(offset)
82
+ whence = case whence
83
+ when :SET then IO::SEEK_SET
84
+ when :CUR then IO::SEEK_CUR
85
+ when :END then IO::SEEK_END
86
+ else whence
87
+ end
88
+ base = case integer(whence)
89
+ when IO::SEEK_SET then 0
90
+ when IO::SEEK_CUR then @pos
91
+ when IO::SEEK_END then @size
92
+ else raise Errno::EINVAL, "invalid seek origin"
93
+ end
94
+ position = base + offset
95
+ raise Errno::EINVAL, "negative seek position" if position.negative?
96
+
97
+ @pos = integer(position)
98
+ 0
99
+ end
100
+
101
+ def rewind
102
+ seek(0)
103
+ end
104
+
105
+ def pos
106
+ check!
107
+ @pos
108
+ end
109
+
110
+ def size
111
+ check!
112
+ @size
113
+ end
114
+
115
+ def eof?
116
+ check!
117
+ @pos >= @size
118
+ end
119
+ end
120
+
121
+ # Spools bytes before the enclosing assignment is published.
122
+ class Writer < Handle
123
+ def initialize(context, spool)
124
+ super(context)
125
+ @spool = spool
126
+ end
127
+
128
+ def write(value)
129
+ check!
130
+ bytes = value.to_s
131
+ raise TypeError, "to_s must return a String" unless bytes.is_a?(String)
132
+
133
+ position = 0
134
+ while position < bytes.bytesize
135
+ check!
136
+ chunk = bytes.byteslice(position, Core::Bytes::CHUNK_SIZE) || raise(EOFError, "byte source ended")
137
+ position += @spool.write(chunk)
138
+ end
139
+ position
140
+ end
141
+
142
+ def pos
143
+ check!
144
+ @spool.pos
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "input_guard"
4
+
5
+ module XitDB
6
+ # Encodes and materializes nested values without recursive tree traversal.
7
+ module ValueWalker
8
+ # Frames retain only the current ancestors and their incremental iterators.
9
+ def self.encode(value, context:, path:)
10
+ # @type var active: ::Hash[Integer, bool]
11
+ active = {}
12
+ # @type var stack: ::Array[encoding_frame]
13
+ stack = []
14
+ # @type var current: [top, path]?
15
+ current = [value, path]
16
+ # @type var result: Core::Slot?
17
+ result = nil
18
+ while true # rubocop:disable Style/InfiniteLoop -- Codec StopIteration must propagate.
19
+ if current
20
+ result = begin_encoding(current[0], current[1], context, active, stack)
21
+ current = nil
22
+ end
23
+ if result
24
+ break if stack.empty?
25
+
26
+ assign_encoding_parent(stack, result)
27
+ end
28
+ frame = stack.last || raise(CorruptionError, "missing encoding frame")
29
+ entry = next_input(frame)
30
+ if entry
31
+ selector, child = entry
32
+ frame[4] = selector
33
+ current = [child, frame[1] + [selector]]
34
+ else
35
+ result = finish_encoding(frame, active, stack)
36
+ break if stack.empty?
37
+
38
+ result = nil
39
+ end
40
+ end
41
+ result || raise(CorruptionError, "encoding produced no value")
42
+ end
43
+
44
+ def self.materialize(view)
45
+ view.check!
46
+ context = view.context
47
+ # @type var active: ::Hash[[Symbol, Integer], bool]
48
+ active = {}
49
+ # @type var stack: ::Array[materialization_frame]
50
+ stack = []
51
+ # @type var current: [Core::Slot, path]?
52
+ current = [view.slot, view.path]
53
+ # @type var selected: adapter | Views::Slice::Selection | nil
54
+ selected = view.adapter
55
+ # @type var materialized: materialized?
56
+ materialized = nil
57
+ while true # rubocop:disable Style/InfiniteLoop -- Codec StopIteration must propagate.
58
+ if current
59
+ begin_materialization(current[0], current[1], selected, context, active, stack)
60
+ current = nil
61
+ selected = nil
62
+ end
63
+ frame = stack.last || raise(CorruptionError, "missing materialization frame")
64
+ entry = next_stored_entry(frame)
65
+ if entry
66
+ current = materialized_child(frame, entry)
67
+ else
68
+ materialized = finish_materialization(frame, active, stack)
69
+ break if stack.empty?
70
+ end
71
+ end
72
+ materialized || raise(CorruptionError, "materialization produced no value")
73
+ end
74
+
75
+ def self.begin_encoding(input, location, context, active, stack)
76
+ InputGuard.check!(input, path: location)
77
+ case input
78
+ when Views::Base then encode_view(input, context, location)
79
+ else
80
+ profile = context.profile
81
+ kind = profile.input_kind(input, path: location)
82
+ return encode_scalar(input, context, location) unless kind
83
+
84
+ identity = BasicObject.instance_method(:__id__).bind_call(input)
85
+ raise CyclicValueError.new("cyclic value at #{location.inspect}", path: location) if active[identity]
86
+
87
+ active[identity] = true
88
+ adapter = profile.empty_container(kind, context: context, path: location)
89
+ stack << [input, location, adapter, profile.input_entries(input, path: location), nil]
90
+ nil
91
+ end
92
+ end
93
+
94
+ def self.encode_view(input, context, location)
95
+ input.check!
96
+ unless input.context.reference_compatible?(context)
97
+ raise ForeignValueError.new("view belongs to another store or file generation", path: location)
98
+ end
99
+
100
+ context.writable_transaction.freeze
101
+ assigned = input.slot_for_assignment(context: context, path: location)
102
+ assigned.empty? ? encode({}, context: context, path: location) : assigned
103
+ end
104
+
105
+ def self.encode_scalar(input, context, location)
106
+ context.profile.encode_scalar(input, core: context.database.core)
107
+ rescue ValueError => e
108
+ raise e.class.new("#{e.message} at #{location.inspect}", path: location), cause: e
109
+ end
110
+
111
+ def self.assign_encoding_parent(stack, stored)
112
+ frame = stack.last || raise(CorruptionError, "missing encoding frame")
113
+ frame[2].assign(frame[4], stored)
114
+ end
115
+
116
+ def self.finish_encoding(frame, active, stack)
117
+ stored = frame[2].slot
118
+ active.delete(BasicObject.instance_method(:__id__).bind_call(frame[0]))
119
+ stack.pop
120
+ assign_encoding_parent(stack, stored) unless stack.empty?
121
+ stored
122
+ end
123
+
124
+ def self.next_input(frame)
125
+ frame[3].next
126
+ rescue StopIteration
127
+ nil
128
+ end
129
+
130
+ def self.begin_materialization(slot, path, selected, context, active, stack)
131
+ adapter = selected || context.profile.container(slot, context: context, path: path)
132
+ unless adapter
133
+ result = context.profile.decode(slot, context: context, path: path, immutable: false)
134
+ frame = stack.last || raise(CorruptionError, "missing materialization frame")
135
+ insert(frame, result)
136
+ return
137
+ end
138
+
139
+ # @type var identity: [Symbol, Integer]
140
+ identity = [slot.tag, slot.value]
141
+ raise CyclicValueError.new("cyclic stored value", path: path) if active[identity]
142
+
143
+ active[identity] = true
144
+ # @type var output: materialized
145
+ output = case adapter.kind
146
+ when :map, :sorted_map then {}
147
+ when :set, :sorted_set then Set.new
148
+ else []
149
+ end
150
+ stack << [identity, path, adapter.each_entry, output, nil]
151
+ end
152
+
153
+ def self.next_stored_entry(frame)
154
+ frame[2].next
155
+ rescue StopIteration
156
+ nil
157
+ end
158
+
159
+ def self.materialized_child(frame, entry)
160
+ selector, child = entry
161
+ frame[4] = selector.is_a?(String) ? selector.dup : selector
162
+ if frame[3].is_a?(Set)
163
+ frame[3].add(frame[4])
164
+ nil
165
+ else
166
+ [child, frame[1] + [selector]]
167
+ end
168
+ end
169
+
170
+ def self.finish_materialization(frame, active, stack)
171
+ active.delete(frame[0])
172
+ stack.pop
173
+ output = frame[3]
174
+ unless stack.empty?
175
+ parent = stack.last || raise(CorruptionError, "missing materialization parent")
176
+ insert(parent, output)
177
+ end
178
+ output
179
+ end
180
+
181
+ def self.insert(frame, value)
182
+ output = frame[3]
183
+ case output
184
+ when Hash then output[frame[4]] = value
185
+ when Array then output << value
186
+ end
187
+ end
188
+ private_class_method :insert, :begin_encoding, :encode_view, :encode_scalar, :assign_encoding_parent,
189
+ :finish_encoding, :next_input, :begin_materialization, :next_stored_entry,
190
+ :materialized_child, :finish_materialization
191
+ end
192
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ module Views
5
+ # Provides indexed reads and writes for persistent arrays.
6
+ class Array < Base
7
+ UNSET = MissingArgument.new.freeze
8
+ private_constant :UNSET
9
+
10
+ alias length size
11
+
12
+ def [](index, count = UNSET)
13
+ context.read { read_index(index, count) }
14
+ end
15
+
16
+ def read_index(index, count)
17
+ return read_slice(integer_argument(index), integer_argument(count)) unless count.equal?(UNSET)
18
+ return read_range(index) if index.is_a?(Range)
19
+
20
+ index = normalize_index(index)
21
+ return nil unless index >= 0 && index < size
22
+
23
+ stored = adapter.lookup(index) || raise(IndexError, "array index outside list")
24
+ context.profile.decode(stored, context: context, path: entry_path(index))
25
+ end
26
+ private :read_index
27
+
28
+ def read_range(index)
29
+ start = index.begin.nil? ? 0 : integer_argument(index.begin)
30
+ finish = index.end.nil? ? size : integer_argument(index.end)
31
+ start += size if start.negative?
32
+ return nil if start.negative? || start > size
33
+
34
+ finish += size if finish.negative?
35
+ finish += 1 unless index.exclude_end? || index.end.nil?
36
+ read_slice(start, [finish - start, 0].max)
37
+ end
38
+ private :read_range
39
+
40
+ def fetch(index, default = UNSET)
41
+ found, value = context.read do
42
+ position = normalize_index(index)
43
+ present = position >= 0 && position < size
44
+ [present, present ? self[position] : nil]
45
+ end
46
+ return value if found
47
+ return yield index if block_given?
48
+
49
+ case default
50
+ when MissingArgument then nil
51
+ else return default
52
+ end
53
+
54
+ raise IndexError, "array index outside list"
55
+ end
56
+
57
+ def first(count = UNSET)
58
+ context.read do
59
+ check!
60
+ return self[0] if count.equal?(UNSET)
61
+
62
+ requested = nonnegative_count(count)
63
+ each.take(requested)
64
+ end
65
+ end
66
+
67
+ def last(count = UNSET)
68
+ context.read do
69
+ return self[-1] if count.equal?(UNSET)
70
+
71
+ requested = [nonnegative_count(count), size].min
72
+ ((size - requested)...size).map { |index| self[index] }
73
+ end
74
+ end
75
+
76
+ def []=(index, value)
77
+ check!
78
+ context.writable!
79
+ index = normalize_index(index)
80
+ raise IndexError, "array index outside list" if index.negative?
81
+
82
+ validate_growth(index + 1)
83
+
84
+ previous_size = size
85
+ edit(positions: [previous_size, index].min..index) do |collection|
86
+ stored = context.profile.encode(value, context: context, path: path + [index])
87
+ collection.assign(collection.size, Core::Slot.null) while collection.size < index
88
+ collection.assign(index, stored)
89
+ end
90
+ context.replace_location(path + [index])
91
+ context.change_membership(path) if index >= previous_size
92
+ value
93
+ end
94
+
95
+ def <<(value)
96
+ push(value)
97
+ end
98
+
99
+ def push(*values)
100
+ insert(size, *values)
101
+ end
102
+
103
+ # Radix arrays shift the suffix for insertion and deletion. Linked arrays
104
+ # use their positional tree operations; neither operation decodes values.
105
+ def insert(index, *values)
106
+ check!
107
+ context.writable!
108
+ index = integer_argument(index)
109
+ return self if values.empty?
110
+
111
+ previous_size = size
112
+ index += previous_size + 1 if index.negative?
113
+ raise IndexError, "array index outside list" if index.negative?
114
+
115
+ validate_growth([previous_size, index].max + values.size)
116
+ edit(positions: [previous_size, index].min..) do |collection|
117
+ encoded = values.each_with_index.map do |value, offset|
118
+ context.profile.encode(value, context: context, path: path + [index + offset])
119
+ end
120
+ collection.assign(collection.size, Core::Slot.null) while collection.size < index
121
+ encoded.each_with_index { |stored, offset| collection.insert(index + offset, stored) }
122
+ end
123
+ invalidate_positions(index)
124
+ self
125
+ end
126
+
127
+ def delete_at(index)
128
+ check!
129
+ context.writable!
130
+ index = normalize_index(index)
131
+ previous_size = size
132
+ return nil unless index >= 0 && index < previous_size
133
+
134
+ context.check_stream_mutation!(path, positions: index..)
135
+
136
+ value = nil
137
+ edit(positions: index..) do |collection|
138
+ captured = captured_context
139
+ stored = collection.lookup(index) || raise(IndexError, "array index outside list")
140
+ value = context.profile.decode(stored, context: captured, path: [index])
141
+ collection.delete(index)
142
+ end
143
+ invalidate_positions(index)
144
+ value
145
+ end
146
+
147
+ def pop(count = UNSET)
148
+ check!
149
+ context.writable!
150
+ return delete_at(-1) if count.equal?(UNSET)
151
+
152
+ count = [nonnegative_count(count), size].min
153
+ return [] if count.zero?
154
+
155
+ previous_size = size
156
+ context.check_stream_mutation!(path, positions: (previous_size - count)..)
157
+ values = nil
158
+ edit(positions: (previous_size - count)..) do |collection|
159
+ captured = captured_context
160
+ values = ((previous_size - count)...previous_size).map do |index|
161
+ stored = collection.lookup(index) || raise(IndexError, "array index outside list")
162
+ context.profile.decode(stored, context: captured, path: [index])
163
+ end
164
+ collection.truncate(previous_size - count)
165
+ end
166
+ invalidate_positions(previous_size - count)
167
+ values
168
+ end
169
+
170
+ def concat(*others)
171
+ check!
172
+ context.writable!
173
+ added = false
174
+ edit(positions: size..) do |collection|
175
+ inputs = others.map do |other|
176
+ case other
177
+ when Array then nil
178
+ else other = ::Array.try_convert(other) || raise(TypeError, "concat requires an array")
179
+ end
180
+ stored = context.profile.encode(other, context: context, path: path)
181
+ context.collection(stored, path: path)
182
+ end
183
+ inputs.each do |input|
184
+ next if input.empty?
185
+
186
+ validate_growth(collection.size + input.size)
187
+ added = true
188
+ collection.concat(input)
189
+ end
190
+ end
191
+ context.change_membership(path) if added
192
+ self
193
+ end
194
+
195
+ def clear
196
+ check!
197
+ context.writable!
198
+ return self if empty?
199
+
200
+ edit { |collection| collection.truncate(0) }
201
+ context.invalidate_children(path)
202
+ context.change_membership(path)
203
+ self
204
+ end
205
+
206
+ protected
207
+
208
+ def validate_growth(count)
209
+ raise IndexError, "array size too big" if count > LONG_MAX / 0.size
210
+ end
211
+
212
+ def normalize_index(index)
213
+ index = integer_argument(index)
214
+ index.negative? ? index + size : index
215
+ end
216
+
217
+ def nonnegative_count(count)
218
+ count = integer_argument(count)
219
+ raise ArgumentError, "negative array size" if count.negative?
220
+
221
+ count
222
+ end
223
+
224
+ def read_slice(start, count)
225
+ start += size if start.negative?
226
+ return nil if start.negative? || start > size || count.negative?
227
+
228
+ count = [count, size - start].min
229
+ Slice.new(context: captured_context(readonly: true), path: [], kind: kind,
230
+ offset: slice_offset + start, count: count)
231
+ end
232
+
233
+ def slice_offset
234
+ 0
235
+ end
236
+
237
+ def invalidate_positions(start)
238
+ context.invalidate_positions(path, start)
239
+ context.change_membership(path)
240
+ end
241
+ end
242
+
243
+ # A captured, read-only range of an array.
244
+ class Slice < Array
245
+ def initialize(offset:, count:, **)
246
+ super(**)
247
+ @offset = offset
248
+ @count = count
249
+ end
250
+
251
+ def adapter
252
+ Selection.new(super, @offset, @count)
253
+ end
254
+
255
+ def slot_for_assignment(context:, path:)
256
+ context.collection(slot, path: path).slice(@offset, @count)
257
+ end
258
+
259
+ protected
260
+
261
+ def slice_offset
262
+ @offset
263
+ end
264
+
265
+ def entry_path(selector)
266
+ path + [@offset + selector]
267
+ end
268
+
269
+ # Limits the adapter's visible entries to the captured range.
270
+ class Selection
271
+ attr_reader :size
272
+
273
+ def initialize(source, offset, count)
274
+ @source = source
275
+ @offset = offset
276
+ @size = count
277
+ end
278
+
279
+ def kind
280
+ @source.kind
281
+ end
282
+
283
+ def lookup(index)
284
+ @source.lookup(@offset + index)
285
+ end
286
+
287
+ def each_entry
288
+ return enum_for(:each_entry) unless block_given?
289
+ return self if size.zero?
290
+
291
+ index = 0
292
+ @source.each_from_index(@offset) do |_selector, stored|
293
+ yield index, stored
294
+ index += 1
295
+ break if index == size
296
+ end
297
+ self
298
+ end
299
+ end
300
+ private_constant :Selection
301
+ end
302
+ end
303
+ end