nyxis 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +53 -0
  3. data/README.md +134 -0
  4. data/nxs.rb +511 -0
  5. metadata +51 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0ebcbfcd1dfc34288e738e81acc006a8cabda0fdc50e9e851c73e55d422dadb2
4
+ data.tar.gz: d1eb4b53469a62e382bbf385cf039307af2a5be59b62ba0db0d6f0abe3dffe73
5
+ SHA512:
6
+ metadata.gz: 74dcb43ae96b59f6a56b52dd64d03efb2074c8f3789a99fbe1889b26ed931cb9280fd418036d837702a28cf040d79722f7d51a120d43788aa239f1ec6dbb4d35
7
+ data.tar.gz: 339bf6357eebf0557ea3d114dbe970eb42f9f796f6665db9ca6369a59c2c63237135b2ff4681b5305bea05a15f1fc6b09c8347e8a836244ae53afd9f354966c6
data/LICENSE ADDED
@@ -0,0 +1,53 @@
1
+ Business Source License 1.1
2
+
3
+ Licensor: Nyxis Authors
4
+
5
+ Licensed Work: Nyxis, including all source code, documentation, examples,
6
+ conformance vectors, tools, packages, and other files in this repository,
7
+ unless a file states a different license.
8
+
9
+ Additional Use Grant:
10
+
11
+ You may use the Licensed Work in production free of charge only if the legal
12
+ entity using the Licensed Work meets at least one of these conditions:
13
+
14
+ 1. The entity generates less than US$1,000,000 in annual gross revenue; or
15
+ 2. The entity processes less than 100 GB of data per calendar month using the
16
+ Licensed Work.
17
+
18
+ If neither condition is true, production use is not granted under this license.
19
+ Any production use by that entity is a license violation unless the entity has
20
+ a separate commercial license from the Licensor.
21
+
22
+ Non-production use, including development, testing, evaluation, benchmarking,
23
+ research, and personal use, is permitted under the Business Source License 1.1.
24
+
25
+ Change Date: 2030-05-20
26
+
27
+ Change License: MIT License
28
+
29
+ For information about alternative licensing or commercial terms, contact the
30
+ Licensor.
31
+
32
+ Terms
33
+
34
+ The Licensor hereby grants you the right to copy, modify, create derivative
35
+ works, redistribute, and make non-production use of the Licensed Work. The
36
+ Licensor may make an Additional Use Grant, above, permitting limited production
37
+ use.
38
+
39
+ Effective on the Change Date, or the fourth anniversary of the first publicly
40
+ available distribution of a specific version of the Licensed Work under this
41
+ License, whichever comes first, the Licensed Work will be licensed under the
42
+ Change License, and the rights granted under this License will terminate.
43
+
44
+ If your use of the Licensed Work does not comply with this License, you must
45
+ cease use immediately. Your rights under this License terminate automatically if
46
+ you violate this License.
47
+
48
+ The Licensed Work is provided "as is", without warranty of any kind, express or
49
+ implied, including but not limited to the warranties of merchantability, fitness
50
+ for a particular purpose, and noninfringement. In no event will the Licensor be
51
+ liable for any claim, damages, or other liability, whether in an action of
52
+ contract, tort, or otherwise, arising from, out of, or in connection with the
53
+ Licensed Work or the use or other dealings in the Licensed Work.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # NXS — Ruby
2
+
3
+ Zero-copy `.nxb` reader for Ruby 3.x. Pure-Ruby implementation with an optional C extension for hot-path columnar scans. No gems required.
4
+
5
+ ## Requirements
6
+
7
+ Ruby 3.0+. The C extension requires a C compiler and Ruby headers (`ruby-dev` / `ruby-devel`).
8
+
9
+ ## Read a file
10
+
11
+ ```ruby
12
+ require_relative "nxs"
13
+
14
+ bytes = File.binread("data.nxb")
15
+ reader = Nxs::Reader.new(bytes)
16
+
17
+ puts reader.record_count # instant — read from tail-index, no parse pass
18
+ obj = reader.record(42) # O(1) seek
19
+ puts obj.get_str("username")
20
+ puts obj.get_f64("score")
21
+ puts obj.get_bool("active")
22
+ puts obj.get_i64("id")
23
+ ```
24
+
25
+ ## Columnar reducers
26
+
27
+ ```ruby
28
+ total = reader.sum_f64("score")
29
+ low = reader.min_f64("score")
30
+ high = reader.max_f64("score")
31
+ ages = reader.sum_i64("age")
32
+ ```
33
+
34
+ ## C extension (hot path)
35
+
36
+ Build once:
37
+
38
+ ```bash
39
+ bash ext/build.sh
40
+ ```
41
+
42
+ ```ruby
43
+ require_relative "ext/nxs/nxs_ext" # loads Nxs::CReader and Nxs::CObject
44
+
45
+ reader = Nxs::CReader.new(bytes)
46
+ puts reader.record(42).get_str("username")
47
+ puts reader.sum_f64("score") # 6.78 ms at 1M records vs 942 ms pure Ruby
48
+ ```
49
+
50
+ At 1M records the C extension is **139× faster** than pure Ruby for `sum_f64`, and **5.6× faster** than `JSON.parse`.
51
+
52
+ ## Write a file
53
+
54
+ ```ruby
55
+ require_relative "nxs_writer"
56
+
57
+ schema = Nxs::Schema.new(["id", "username", "score", "active"])
58
+ w = Nxs::Writer.new(schema)
59
+
60
+ w.begin_object
61
+ w.write_i64(0, 42)
62
+ w.write_str(1, "alice")
63
+ w.write_f64(2, 9.5)
64
+ w.write_bool(3, true)
65
+ w.end_object
66
+
67
+ data = w.finish # binary String (encoding ASCII-8BIT)
68
+
69
+ # Convenience: write from an array of hashes
70
+ data2 = Nxs::Writer.from_records(
71
+ ["id", "username", "score"],
72
+ [{ "id" => 1, "username" => "bob", "score" => 8.2 }]
73
+ )
74
+ ```
75
+
76
+ ## Tests
77
+
78
+ ```bash
79
+ ruby test.rb ../js/fixtures # 22 tests
80
+ ```
81
+
82
+ ## Benchmarks
83
+
84
+ ```bash
85
+ ruby bench.rb ../js/fixtures # pure Ruby vs JSON
86
+ ruby bench_c.rb ../js/fixtures # C extension vs JSON
87
+ ```
88
+
89
+ ## Files
90
+
91
+ | File | Purpose |
92
+ | :--- | :--- |
93
+ | `nxs.rb` | Pure-Ruby reader (`Nxs::Reader`, `Nxs::Object`) |
94
+ | `nxs_writer.rb` | Pure-Ruby writer (`Nxs::Schema`, `Nxs::Writer`) |
95
+ | `ext/nxs/nxs_ext.c` | C extension source (`Nxs::CReader`, `Nxs::CObject`) |
96
+ | `ext/nxs/extconf.rb` | Extension build configuration |
97
+ | `ext/build.sh` | Compiles the C extension |
98
+
99
+ ## Query engine
100
+
101
+ ```ruby
102
+ require_relative 'nxs'
103
+
104
+ data = File.binread("data.nxb")
105
+ reader = Nxs::Reader.new(data)
106
+
107
+ # Count matching records
108
+ n = reader.where(Nxs::Eq.new("active", true) & Nxs::Gt.new("score", 80.0)).count
109
+
110
+ # Iterate — yields Nxs::Object
111
+ reader.where(Nxs::Eq.new("active", true)).each do |obj|
112
+ puts obj.get_str("username")
113
+ end
114
+
115
+ # First match or nil
116
+ first = reader.where(Nxs::Gt.new("score", 99.0)).first
117
+
118
+ # All records
119
+ reader.all.each { |obj| ... }
120
+ ```
121
+
122
+ ### Predicates
123
+
124
+ | Class | Matches |
125
+ |-------|---------|
126
+ | `Eq.new(key, value)` | equality — String, Integer, Float, boolean |
127
+ | `Gt.new(key, v)` / `Lt.new(key, v)` | numeric comparison |
128
+ | `p1 & p2` / `p1 \| p2` / `~p` | And / Or / Not via operator overloads |
129
+
130
+ `Query` includes `Enumerable` — all `map`, `select`, `reject` etc. are available.
131
+
132
+ ---
133
+
134
+ For the format specification see [`SPEC.md`](../SPEC.md). For cross-language examples see [`GETTING_STARTED.md`](../GETTING_STARTED.md).
data/nxs.rb ADDED
@@ -0,0 +1,511 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NXS Reader — .nxb parser (Ruby 3.x, stdlib only).
4
+ #
5
+ # Implements Nyxis v1.1 binary wire format.
6
+ #
7
+ # Usage:
8
+ # buf = File.binread("data.nxb")
9
+ # reader = Nxs::Reader.new(buf)
10
+ # reader.record_count # => Integer
11
+ # reader.keys # => Array<String>
12
+ # obj = reader.record(42) # => Nxs::Object
13
+ # obj.get_str("username") # => String | nil
14
+ # obj.get_i64("id") # => Integer | nil
15
+ # obj.get_f64("score") # => Float | nil
16
+ # obj.get_bool("active") # => true/false | nil
17
+ # reader.sum_f64("score") # => Float
18
+ # reader.min_f64("score") # => Float | nil
19
+ # reader.max_f64("score") # => Float | nil
20
+ # reader.sum_i64("id") # => Integer
21
+
22
+ module Nxs
23
+ MAGIC_FILE = 0x4E595842 # NYXB
24
+ MAGIC_OBJ = 0x4E59584F # NYXO
25
+ MAGIC_FOOTER = 0x2153584E # NXS!
26
+ FLAG_SCHEMA = 0x0002
27
+
28
+ class NxsError < StandardError
29
+ attr_reader :code
30
+
31
+ def initialize(code, msg)
32
+ super("#{code}: #{msg}")
33
+ @code = code
34
+ end
35
+ end
36
+
37
+ # ── Reader ──────────────────────────────────────────────────────────────────
38
+
39
+ class Reader
40
+ attr_reader :keys, :record_count
41
+
42
+ def initialize(bytes)
43
+ @data = bytes.b # force binary encoding
44
+ sz = @data.bytesize
45
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', 'file too small') if sz < 32
46
+
47
+ magic = @data.unpack1('L<')
48
+ raise NxsError.new('ERR_BAD_MAGIC', "expected NYXB, got 0x#{magic.to_s(16)}") if magic != MAGIC_FILE
49
+
50
+ footer = @data.unpack1("@#{sz - 4}L<")
51
+ raise NxsError.new('ERR_BAD_MAGIC', 'footer magic mismatch') if footer != MAGIC_FOOTER
52
+
53
+ # Preamble: Version(2) + Flags(2) + DictHash(8) + TailPtr(8) + Reserved(8)
54
+ @flags = @data.unpack1('@6 S<')
55
+ @tail_ptr = @data.unpack1('@16 Q<')
56
+ if @tail_ptr.zero?
57
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', 'stream footer') if sz < 44
58
+
59
+ @tail_ptr = @data.unpack1("@#{sz - 12}Q<")
60
+ end
61
+
62
+ @dict_hash = @data.unpack1('@8 Q<')
63
+
64
+ # Schema (when Flags bit 1 set)
65
+ @keys = []
66
+ @key_sigils = []
67
+ @key_index = {}
68
+ if @flags & FLAG_SCHEMA != 0
69
+ schema_end = read_schema(32)
70
+ computed = murmur3_64(@data[32...schema_end].bytes)
71
+ raise NxsError.new('ERR_DICT_MISMATCH', 'schema hash mismatch') if computed != @dict_hash
72
+ end
73
+
74
+ # Tail-index: u32 EntryCount followed by records
75
+ @record_count = @data.unpack1("@#{@tail_ptr}L<")
76
+ @tail_start = @tail_ptr + 4
77
+ end
78
+
79
+ # O(1) record lookup — reads one 10-byte tail-index entry.
80
+ def record(i)
81
+ unless i >= 0 && i < @record_count
82
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', "record #{i} out of [0, #{@record_count})")
83
+ end
84
+
85
+ # Each tail-index entry: u16 KeyID + u64 AbsoluteOffset = 10 bytes
86
+ abs_offset = @data.unpack1("@#{@tail_start + i * 10 + 2}Q<")
87
+ Object.new(self, abs_offset)
88
+ end
89
+
90
+ # Tight allocation-free sum loop.
91
+ def sum_f64(key)
92
+ slot = @key_index[key]
93
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
94
+
95
+ data = @data
96
+ tail = @tail_start
97
+ n = @record_count
98
+ sum = 0.0
99
+ i = 0
100
+ while i < n
101
+ abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
102
+ off = _scan_offset(data, abs, slot)
103
+ sum += data.unpack1("@#{off}E") if off
104
+ i += 1
105
+ end
106
+ sum
107
+ end
108
+
109
+ def min_f64(key)
110
+ slot = @key_index[key]
111
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
112
+
113
+ data = @data
114
+ tail = @tail_start
115
+ n = @record_count
116
+ min = nil
117
+ i = 0
118
+ while i < n
119
+ abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
120
+ off = _scan_offset(data, abs, slot)
121
+ if off
122
+ v = data.unpack1("@#{off}E")
123
+ min = v if min.nil? || v < min
124
+ end
125
+ i += 1
126
+ end
127
+ min
128
+ end
129
+
130
+ def max_f64(key)
131
+ slot = @key_index[key]
132
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
133
+
134
+ data = @data
135
+ tail = @tail_start
136
+ n = @record_count
137
+ max = nil
138
+ i = 0
139
+ while i < n
140
+ abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
141
+ off = _scan_offset(data, abs, slot)
142
+ if off
143
+ v = data.unpack1("@#{off}E")
144
+ max = v if max.nil? || v > max
145
+ end
146
+ i += 1
147
+ end
148
+ max
149
+ end
150
+
151
+ def sum_i64(key)
152
+ slot = @key_index[key]
153
+ raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
154
+
155
+ data = @data
156
+ tail = @tail_start
157
+ n = @record_count
158
+ sum = 0
159
+ i = 0
160
+ while i < n
161
+ abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
162
+ off = _scan_offset(data, abs, slot)
163
+ sum += data.unpack1("@#{off}q<") if off
164
+ i += 1
165
+ end
166
+ sum
167
+ end
168
+
169
+ # Expose internals for Object
170
+ attr_reader :data, :key_index
171
+
172
+ # Walk the LEB128 bitmask from obj_offset+8, count set bits before `slot`,
173
+ # and return the absolute byte offset of the field value (or nil if absent).
174
+ # Used by both bulk reducers and NxsObject.
175
+ def _scan_offset(data, obj_offset, slot)
176
+ p = obj_offset + 8 # skip Magic(4) + Length(4)
177
+ cur = 0
178
+ t_idx = 0
179
+
180
+ loop do
181
+ b = data.getbyte(p)
182
+ p += 1
183
+ bits = b & 0x7F
184
+ 7.times do |i|
185
+ if cur == slot
186
+ # field absent if bit is 0
187
+ return nil if ((bits >> i) & 1).zero?
188
+
189
+ # p already past this bitmask byte; drain remaining continuation bytes
190
+ while (b & 0x80) != 0
191
+ b = data.getbyte(p)
192
+ p += 1
193
+ end
194
+ # p now points to the offset table
195
+ rel = data.unpack1("@#{p + t_idx * 2}S<")
196
+ return obj_offset + rel
197
+ end
198
+ t_idx += 1 if (bits >> i) & 1 == 1
199
+ cur += 1
200
+ end
201
+ # If all 7 bits processed and continuation bit clear, field is absent
202
+ return nil if (b & 0x80).zero?
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ def read_schema(offset)
209
+ key_count = @data.unpack1("@#{offset}S<")
210
+ offset += 2
211
+
212
+ @key_sigils = @data[offset, key_count].bytes
213
+ offset += key_count
214
+
215
+ # Null-terminated UTF-8 strings in StringPool
216
+ pool = @data[offset..]
217
+ pos = 0
218
+ key_count.times do |i|
219
+ term = pool.index("\x00", pos)
220
+ @keys << pool[pos...term].force_encoding('UTF-8')
221
+ @key_index[@keys.last] = i
222
+ pos = term + 1
223
+ end
224
+ offset += pos
225
+
226
+ # Pad to 8-byte boundary
227
+ rem = offset % 8
228
+ offset += (8 - rem) % 8
229
+ offset
230
+ end
231
+
232
+ MURMUR_C1 = 0xFF51AFD7ED558CCD
233
+ MURMUR_C2 = 0xC4CEB9FE1A85EC53
234
+ MURMUR_MASK = 0xFFFFFFFFFFFFFFFF
235
+
236
+ def murmur3_64(bytes)
237
+ h = 0x93681D6255313A99
238
+ i = 0
239
+ len = bytes.length
240
+ while i < len
241
+ chunk = bytes[i, 8]
242
+ k = 0
243
+ chunk.each_with_index { |b, j| k |= b << (j * 8) }
244
+ k = (k * MURMUR_C1) & MURMUR_MASK
245
+ k ^= k >> 33
246
+ h ^= k
247
+ h = (h * MURMUR_C2) & MURMUR_MASK
248
+ h ^= h >> 33
249
+ i += 8
250
+ end
251
+ h ^= len
252
+ h ^= h >> 33
253
+ h = (h * MURMUR_C1) & MURMUR_MASK
254
+ h ^= h >> 33
255
+ h
256
+ end
257
+ end
258
+
259
+ # ── Query engine ─────────────────────────────────────────────────────────────
260
+
261
+ # Base predicate — supports & | ~ operator overloading.
262
+ class Predicate
263
+ def &(other) = And.new(self, other)
264
+ def |(other) = Or.new(self, other)
265
+ def ~@ = Not.new(self)
266
+ def call(_record) = raise NotImplementedError, "#{self.class}#call not implemented"
267
+ end
268
+
269
+ # Eq(key, value) — equality for String, Integer, Float, or boolean.
270
+ class Eq < Predicate
271
+ def initialize(key, value)
272
+ super()
273
+ @key = key
274
+ @value = value
275
+ end
276
+
277
+ def call(record) = record[@key] == @value
278
+ end
279
+
280
+ # Gt(key, number) — numeric greater-than.
281
+ class Gt < Predicate
282
+ def initialize(key, value)
283
+ super()
284
+ @key = key
285
+ @value = value
286
+ end
287
+
288
+ def call(record)
289
+ v = record[@key]
290
+ v.is_a?(Numeric) && v > @value
291
+ end
292
+ end
293
+
294
+ # Lt(key, number) — numeric less-than.
295
+ class Lt < Predicate
296
+ def initialize(key, value)
297
+ super()
298
+ @key = key
299
+ @value = value
300
+ end
301
+
302
+ def call(record)
303
+ v = record[@key]
304
+ v.is_a?(Numeric) && v < @value
305
+ end
306
+ end
307
+
308
+ # And(p1, p2) — conjunction.
309
+ class And < Predicate
310
+ def initialize(a, b)
311
+ super()
312
+ @a = a
313
+ @b = b
314
+ end
315
+
316
+ def call(record) = @a.call(record) && @b.call(record)
317
+ end
318
+
319
+ # Or(p1, p2) — disjunction.
320
+ class Or < Predicate
321
+ def initialize(a, b)
322
+ super()
323
+ @a = a
324
+ @b = b
325
+ end
326
+
327
+ def call(record) = @a.call(record) || @b.call(record)
328
+ end
329
+
330
+ # Not(p) — negation.
331
+ class Not < Predicate
332
+ def initialize(inner)
333
+ super()
334
+ @inner = inner
335
+ end
336
+
337
+ def call(record) = !@inner.call(record)
338
+ end
339
+
340
+ # ── Record proxy ─────────────────────────────────────────────────────────────
341
+
342
+ # Thin hash-like wrapper around Nxs::Object so predicates can use record[key].
343
+ # Values are fetched lazily and memoised per field access.
344
+ class RecordProxy
345
+ def initialize(obj, reader)
346
+ @obj = obj
347
+ @reader = reader
348
+ @cache = {}
349
+ end
350
+
351
+ # Reset to a new underlying object, clearing the field cache.
352
+ # Used by Query#each to reuse a single RecordProxy instance across iterations.
353
+ def reset(obj)
354
+ @obj = obj
355
+ @cache = {}
356
+ end
357
+
358
+ def [](key)
359
+ return @cache[key] if @cache.key?(key)
360
+
361
+ slot = @reader.key_index[key]
362
+ unless slot
363
+ @cache[key] = nil
364
+ return nil
365
+ end
366
+
367
+ sigil = @reader.key_sigils[slot]
368
+ val = case sigil
369
+ when 0x22 then @obj.get_str(key) # '"' string
370
+ when 0x3D then @obj.get_i64(key) # '=' i64
371
+ when 0x7E then @obj.get_f64(key) # '~' f64
372
+ when 0x3F then @obj.get_bool(key) # '?' bool
373
+ end
374
+ @cache[key] = val
375
+ end
376
+ end
377
+
378
+ # ── Query ─────────────────────────────────────────────────────────────────────
379
+
380
+ # Lazy filtered view over a Reader. Created via reader.where(pred) or reader.all.
381
+ #
382
+ # Includes Enumerable, so map/select/min/max etc. all work automatically.
383
+ # count and first are overridden for clarity (Enumerable would work too).
384
+ class Query
385
+ include Enumerable
386
+
387
+ def initialize(reader, pred = nil)
388
+ @reader = reader
389
+ @pred = pred
390
+ end
391
+
392
+ # Yield each matching Nxs::Object to the block.
393
+ def each
394
+ n = @reader.record_count
395
+ pred = @pred
396
+ proxy = RecordProxy.new(nil, @reader)
397
+ i = 0
398
+ while i < n
399
+ obj = @reader.record(i)
400
+ if pred.nil?
401
+ yield obj
402
+ else
403
+ proxy.reset(obj)
404
+ yield obj if pred.call(proxy)
405
+ end
406
+ i += 1
407
+ end
408
+ end
409
+
410
+ # Number of matching records (no block form; delegates to Enumerable when block given).
411
+ def count(&blk)
412
+ return super if blk
413
+
414
+ n = 0
415
+ each { n += 1 }
416
+ n
417
+ end
418
+
419
+ # First matching record, or nil.
420
+ def first
421
+ find { true }
422
+ end
423
+ end
424
+
425
+ # ── Reader extensions ────────────────────────────────────────────────────────
426
+
427
+ class Reader
428
+ # Returns a Query filtered by pred.
429
+ def where(pred) = Query.new(self, pred)
430
+
431
+ # Returns a Query over all records.
432
+ def all = Query.new(self)
433
+
434
+ # Expose key_sigils for RecordProxy
435
+ attr_reader :key_sigils
436
+ end
437
+
438
+ # ── Object ───────────────────────────────────────────────────────────────────
439
+
440
+ class Object
441
+ def initialize(reader, offset)
442
+ @reader = reader
443
+ @offset = offset
444
+ @parsed = false
445
+ end
446
+
447
+ def get_str(key)
448
+ off = field_offset(key)
449
+ return nil unless off
450
+
451
+ len = @reader.data.unpack1("@#{off}L<")
452
+ @reader.data[off + 4, len].force_encoding('UTF-8')
453
+ end
454
+
455
+ def get_i64(key)
456
+ off = field_offset(key)
457
+ return nil unless off
458
+
459
+ @reader.data.unpack1("@#{off}q<")
460
+ end
461
+
462
+ def get_f64(key)
463
+ off = field_offset(key)
464
+ return nil unless off
465
+
466
+ @reader.data.unpack1("@#{off}E")
467
+ end
468
+
469
+ def get_bool(key)
470
+ off = field_offset(key)
471
+ return nil unless off
472
+
473
+ @reader.data.getbyte(off) != 0
474
+ end
475
+
476
+ private
477
+
478
+ # Parse the object header (lazy — only on first field access).
479
+ def parse_header
480
+ return if @parsed
481
+
482
+ p = @offset
483
+
484
+ magic = @reader.data.unpack1("@#{p}L<")
485
+ raise NxsError.new('ERR_BAD_MAGIC', "expected NYXO at #{p}") if magic != MAGIC_OBJ
486
+
487
+ p += 8 # skip Magic(4) + Length(4)
488
+
489
+ bitmask = []
490
+ loop do
491
+ b = @reader.data.getbyte(p)
492
+ p += 1
493
+ bitmask << (b & 0x7F)
494
+ break if (b & 0x80).zero?
495
+ end
496
+
497
+ @bitmask = bitmask
498
+ @offset_tbl_start = p
499
+ @parsed = true
500
+ end
501
+
502
+ # Return the absolute byte offset of the field for `key`, or nil.
503
+ def field_offset(key)
504
+ slot = @reader.key_index[key]
505
+ return nil unless slot
506
+
507
+ # Delegate to Reader's scan logic (same implementation, avoids duplication)
508
+ @reader._scan_offset(@reader.data, @offset, slot)
509
+ end
510
+ end
511
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nyxis
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Micael Malta
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Pure-Ruby reader for NXB files produced by the NXS compiler. Provides
15
+ zero-copy memory-mapped access to typed records with O(1) random access
16
+ via the tail-index.
17
+ email:
18
+ - micael@example.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - LICENSE
24
+ - README.md
25
+ - nxs.rb
26
+ homepage: https://github.com/nyxis-io/nyxis-drivers
27
+ licenses:
28
+ - BUSL-1.1
29
+ metadata:
30
+ source_code_uri: https://github.com/nyxis-io/nyxis-drivers
31
+ changelog_uri: https://github.com/nyxis-io/nyxis-drivers/releases
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - "."
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Zero-copy reader for the Nyxis (NXS) binary format
51
+ test_files: []