bzync-nextsql 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.
@@ -0,0 +1,1126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "json"
6
+ require "bigdecimal"
7
+
8
+ require_relative "errors"
9
+
10
+ module NextSQL
11
+ # Wire encoding/decoding for NSQL v1 — mirrors drivers/php/src/Protocol.php
12
+ # and drivers/go/nextsql.go byte-for-byte. Keep the three in sync: this is
13
+ # a faithful reimplementation, not an independent design.
14
+ module Protocol
15
+ VERSION = 1
16
+ MAX_PACKET = 1 << 20
17
+ MAX_SQL = 1 << 20
18
+ MAX_NAME = 256
19
+ MAX_PARAMS = 256
20
+ MAX_ENUM_LABELS = 4096
21
+ MAX_ENUM_LABEL_BYTES = 255
22
+
23
+ TYPE_HELLO = 1
24
+ TYPE_HELLO_OK = 2
25
+ TYPE_AUTH = 3
26
+ TYPE_AUTH_OK = 4
27
+ TYPE_QUERY = 5
28
+ TYPE_PREPARE = 6
29
+ TYPE_PREPARE_OK = 7
30
+ TYPE_EXECUTE = 8
31
+ TYPE_CLOSE_STMT = 9
32
+ TYPE_CLOSE_OK = 10
33
+ TYPE_FLOW_ACK = 11
34
+ TYPE_CANCEL = 12
35
+ TYPE_TERMINATE = 13
36
+ TYPE_ROW_DESC = 14
37
+ TYPE_DATA_BATCH = 15
38
+ TYPE_COMMAND_COMPLETE = 16
39
+ TYPE_ERROR = 17
40
+ TYPE_READY = 18
41
+ TYPE_UNLOCK = 19
42
+ TYPE_UNLOCK_OK = 20
43
+ TYPE_IDEMPOTENT_QUERY = 21
44
+ TYPE_SET_READ_CONSISTENCY = 22
45
+ TYPE_NODE_STATUS = 23
46
+ TYPE_NODE_STATUS_RESP = 24
47
+
48
+ # Read-consistency modes. Values match the wire byte ordering.
49
+ READ_STRONG = 0
50
+ READ_BOUNDED = 1
51
+ READ_STALE = 2
52
+
53
+ AUTH_PASSWORD = 1
54
+ AUTH_PASSWORD_KEY = 2
55
+ FLAG_CANCEL = 1
56
+ FLAG_NULL = 0x01
57
+
58
+ KIND_UUID = 1
59
+ KIND_STRING = 2
60
+ KIND_TEXT = 3
61
+ KIND_DECIMAL = 4
62
+ KIND_TIMESTAMPTZ = 5
63
+ KIND_JSON = 6
64
+ KIND_VECTOR = 7
65
+ KIND_BOOL = 8
66
+ KIND_NULL = 9
67
+ KIND_POINT = 10
68
+ KIND_BOX = 11
69
+ KIND_LINE = 12
70
+ KIND_POLYGON = 13
71
+ KIND_BLOB = 14
72
+ KIND_INT8 = 15
73
+ KIND_INT16 = 16
74
+ KIND_INT32 = 17
75
+ KIND_INT64 = 18
76
+ KIND_UINT8 = 19
77
+ KIND_UINT16 = 20
78
+ KIND_UINT32 = 21
79
+ KIND_UINT64 = 22
80
+ KIND_DATE = 23
81
+ KIND_TIME = 24
82
+ KIND_CHAR = 25
83
+ KIND_VARCHAR = 26
84
+ KIND_TIMESTAMP = 27
85
+ KIND_FLOAT32 = 28
86
+ KIND_FLOAT64 = 29
87
+ KIND_ENUM = 30
88
+ KIND_INTERVAL = 31
89
+ KIND_STRUCT = 32
90
+ KIND_ARRAY = 33
91
+ KIND_MAP = 34
92
+ KIND_GEOMETRY = 35
93
+ KIND_GEOGRAPHY = 36
94
+ MAX_NEST_DEPTH = 8
95
+ MAX_STRUCT_FIELDS = 128
96
+ MAX_COLLECTION_LEN = 1 << 20
97
+
98
+ Column = Struct.new(:name, :kind, :labels, :coll_type)
99
+ NodeStatus = Struct.new(:role, :has_leader, :healthy, :applied_lsn, :last_contact_ms, :apply_backlog)
100
+
101
+ # Dense (values), reference (ref=true, dim only), or sparse
102
+ # (indices + values) VECTOR/BITVECTOR/SPARSEVECTOR payload.
103
+ Vector = Struct.new(:dim, :values, :indices, :ref) do
104
+ def initialize(dim:, values: [], indices: nil, ref: false)
105
+ super(dim, values, indices, ref)
106
+ end
107
+ end
108
+
109
+ Point = Struct.new(:lon, :lat)
110
+ Box = Struct.new(:west, :south, :east, :north)
111
+ Line = Struct.new(:coords)
112
+ Polygon = Struct.new(:rings)
113
+
114
+ # Int8/16/32/64 (D2, Datatype expansion track): explicit fixed-width int
115
+ # wrappers. A bare Integer still defaults to KIND_DECIMAL (see
116
+ # encode_param) and coerces server-side into any numeric column — these
117
+ # are only needed to pin an exact wire width (Ruby Integer is arbitrary
118
+ # precision, so there is no natural bare-value mapping to one width).
119
+ Int8 = Struct.new(:value)
120
+ Int16 = Struct.new(:value)
121
+ Int32 = Struct.new(:value)
122
+ Int64 = Struct.new(:value)
123
+
124
+ # Uint8/16/32/64 (D3, Datatype expansion track): explicit fixed-width
125
+ # unsigned int wrappers, mirroring Int8/16/32/64 above.
126
+ Uint8 = Struct.new(:value)
127
+ Uint16 = Struct.new(:value)
128
+ Uint32 = Struct.new(:value)
129
+ Uint64 = Struct.new(:value)
130
+
131
+ # EnumValue (D11, Datatype expansion track): an explicit ENUM parameter
132
+ # wrapper (value:, labels:). Ordinary INSERT/UPDATE params can just pass
133
+ # a plain String — the server coerces STRING -> ENUM against the
134
+ # destination column, same as a SQL string literal. This wrapper exists
135
+ # for explicit round-tripping and mirrors Int8/Uint8's precedent.
136
+ EnumValue = Struct.new(:value, :labels) do
137
+ def initialize(value:, labels:)
138
+ super(value, labels)
139
+ end
140
+ end
141
+
142
+ # NaiveTimestamp/TimeOfDay/Float32/Float64 (D7/D5/D8, Datatype expansion
143
+ # track): explicit wrappers for types with no unambiguous native Ruby
144
+ # mapping. A bare Time/DateTime already means TimestampTZ (see below), so
145
+ # NaiveTimestamp is required to select the no-timezone Kind instead.
146
+ # Ruby has no time-only stdlib class, so TimeOfDay carries nanoseconds
147
+ # since midnight directly. Date needs no wrapper: it is unambiguous (not
148
+ # a Time/DateTime superclass — DateTime is Date's *subclass*, checked
149
+ # first below) and has no prior meaning to conflict with.
150
+ NaiveTimestamp = Struct.new(:value) do
151
+ def initialize(value:)
152
+ super(value)
153
+ end
154
+ end
155
+ TimeOfDay = Struct.new(:nanos_since_midnight) do
156
+ def initialize(nanos_since_midnight:)
157
+ super(nanos_since_midnight)
158
+ end
159
+ end
160
+ Float32 = Struct.new(:value)
161
+ Float64 = Struct.new(:value)
162
+
163
+ # Interval (D6, Datatype expansion track): months (Integer, calendar) +
164
+ # days (Integer, calendar) + nanos (Integer, time-of-day component) —
165
+ # Postgres-style 3-field storage. A plain String still works as an
166
+ # INTERVAL param for INSERT/UPDATE column assignment (server-side
167
+ # Coerce) but not inside an arithmetic expression like `dur + $1`,
168
+ # which requires the actual wire Kind.
169
+ # StructValue / MapValue (Collections track, docs/design-collections.md):
170
+ # explicit STRUCT / MAP parameter wrappers. StructValue.fields is an
171
+ # ordered Array of [name, value]; MapValue.entries is a Hash or an Array
172
+ # of [key, value].
173
+ StructValue = Struct.new(:fields, keyword_init: true)
174
+ MapValue = Struct.new(:entries, keyword_init: true)
175
+
176
+ # Geometry (Spatial track, docs/design-spatial.md): a decoded GEOMETRY /
177
+ # GEOGRAPHY value. +type+ is the OGC subtype name, +coordinates+ nested
178
+ # per type (Point: [x, y]); a GeometryCollection uses +geometries+
179
+ # instead. Also doubles as an explicit param wrapper: Geometry.new(type:
180
+ # nil, srid: 4326, wkt: "POINT(1 2)").
181
+ Geometry = Struct.new(:type, :srid, :coordinates, :geometries, :wkt, keyword_init: true) do
182
+ def to_wkt
183
+ return wkt if wkt
184
+
185
+ pt = ->(xy) { "#{xy[0]} #{xy[1]}" }
186
+ ring = ->(r) { "(#{r.map(&pt).join(', ')})" }
187
+ case type
188
+ when 'Point' then "POINT(#{pt.call(coordinates)})"
189
+ when 'LineString' then "LINESTRING(#{coordinates.map(&pt).join(', ')})"
190
+ when 'Polygon' then "POLYGON(#{coordinates.map(&ring).join(', ')})"
191
+ when 'MultiPoint' then "MULTIPOINT(#{coordinates.map { |c| "(#{pt.call(c)})" }.join(', ')})"
192
+ when 'MultiLineString' then "MULTILINESTRING(#{coordinates.map(&ring).join(', ')})"
193
+ when 'MultiPolygon'
194
+ "MULTIPOLYGON(#{coordinates.map { |poly| "(#{poly.map(&ring).join(', ')})" }.join(', ')})"
195
+ when 'GeometryCollection'
196
+ "GEOMETRYCOLLECTION(#{geometries.map(&:to_wkt).join(', ')})"
197
+ else
198
+ raise Error.new('invalid_argument', 'unsupported geometry type')
199
+ end
200
+ end
201
+ end
202
+
203
+ Interval = Struct.new(:months, :days, :nanos) do
204
+ def initialize(months:, days:, nanos:)
205
+ super(months, days, nanos)
206
+ end
207
+ end
208
+
209
+ class ProtocolError < Error
210
+ def initialize(message)
211
+ super("protocol", message)
212
+ end
213
+ end
214
+
215
+ module_function
216
+
217
+ def need!(b, off, n, what)
218
+ raise ProtocolError, "truncated #{what}" if off + n > b.bytesize
219
+ end
220
+
221
+ def u16(b, off)
222
+ need!(b, off, 2, "u16")
223
+ b.byteslice(off, 2).unpack1("v")
224
+ end
225
+
226
+ def u32(b, off)
227
+ need!(b, off, 4, "u32")
228
+ b.byteslice(off, 4).unpack1("V")
229
+ end
230
+
231
+ def u64(b, off)
232
+ need!(b, off, 8, "u64")
233
+ b.byteslice(off, 8).unpack1("Q<")
234
+ end
235
+
236
+ def i64(b, off)
237
+ need!(b, off, 8, "i64")
238
+ b.byteslice(off, 8).unpack1("q<")
239
+ end
240
+
241
+ def u16le(n) = [n].pack("v")
242
+ def u32le(n) = [n].pack("V")
243
+ def u64le(n) = [n & 0xFFFFFFFFFFFFFFFF].pack("Q<")
244
+
245
+ def u16str(s, max = MAX_NAME)
246
+ raw = s.to_s.b
247
+ raise ProtocolError, "string exceeds limit" if raw.bytesize > max || raw.bytesize > 0xFFFF
248
+
249
+ u16le(raw.bytesize) + raw
250
+ end
251
+
252
+ def u32bytes(raw, max)
253
+ raw = raw.b
254
+ raise ProtocolError, "bytes exceed limit" if raw.bytesize > max
255
+
256
+ u32le(raw.bytesize) + raw
257
+ end
258
+
259
+ def read_u16_string(b, off, max)
260
+ need!(b, off, 2, "string length")
261
+ n = u16(b, off)
262
+ raise ProtocolError, "truncated string" if n > max
263
+
264
+ need!(b, off + 2, n, "string")
265
+ [b.byteslice(off + 2, n).force_encoding("UTF-8"), off + 2 + n]
266
+ end
267
+
268
+ def read_u32_bytes(b, off, max)
269
+ need!(b, off, 4, "bytes length")
270
+ n = u32(b, off)
271
+ raise ProtocolError, "truncated bytes" if n > max
272
+
273
+ need!(b, off + 4, n, "bytes")
274
+ [b.byteslice(off + 4, n), off + 4 + n]
275
+ end
276
+
277
+ # append_enum_labels/read_enum_labels carry an ENUM Type's declared
278
+ # label list on the wire, matching internal/protocol/value.go's
279
+ # appendEnumLabels/readEnumLabels exactly (docs/design-datatypes.md
280
+ # D11): ENUM is the first D-track type whose Type needs variable-length
281
+ # metadata beyond the fixed 5/6-byte Precision/Scale/VecElem shape every
282
+ # other type fits into.
283
+ def append_enum_labels(labels)
284
+ out = +u16le(labels.size)
285
+ labels.each { |l| out << u16str(l, MAX_ENUM_LABEL_BYTES) }
286
+ out
287
+ end
288
+
289
+ def read_enum_labels(b, off)
290
+ need!(b, off, 2, "enum label count")
291
+ n = u16(b, off)
292
+ raise ProtocolError, "enum label count exceeds limit" if n > MAX_ENUM_LABELS
293
+
294
+ off += 2
295
+ labels = []
296
+ n.times do
297
+ label, next_off = read_u16_string(b, off, MAX_ENUM_LABEL_BYTES)
298
+ labels << label
299
+ off = next_off
300
+ end
301
+ [labels, off]
302
+ end
303
+
304
+ def encode_hello(version, flags, secret, database, user, realm = "")
305
+ sec = (secret || "").b
306
+ sec = sec[0, 8].ljust(8, "\x00")
307
+ out = u16le(version) + u16le(flags) + sec + u16str(database) + u16str(user)
308
+ # Realm is an optional trailing field (M2-2): emitted only when
309
+ # selected, so a Hello with no realm is byte-identical to the
310
+ # pre-realm wire shape.
311
+ out << u16str(realm) unless realm.nil? || realm.empty?
312
+ out
313
+ end
314
+
315
+ def decode_hello_ok(b)
316
+ raise ProtocolError, "bad hello-ok length" unless b.bytesize == 11
317
+
318
+ [u16(b, 0), b.getbyte(2), b.byteslice(3, 8)]
319
+ end
320
+
321
+ def encode_query(sql, params)
322
+ raise ProtocolError, "too many parameters" if params.size > MAX_PARAMS
323
+
324
+ out = +u32bytes(sql.to_s.b, MAX_SQL)
325
+ out << u16le(params.size)
326
+ params.each { |v| out << u16str("") << encode_param(v) }
327
+ out
328
+ end
329
+
330
+ def encode_execute(stmt_id, params)
331
+ raise ProtocolError, "too many parameters" if params.size > MAX_PARAMS
332
+
333
+ out = +u32le(stmt_id)
334
+ out << u16le(params.size)
335
+ params.each { |v| out << u16str("") << encode_param(v) }
336
+ out
337
+ end
338
+
339
+ def encode_idempotent_query(key, sql, params)
340
+ raise ProtocolError, "too many parameters" if params.size > MAX_PARAMS
341
+
342
+ out = +u16str(key)
343
+ out << u32bytes(sql.to_s.b, MAX_SQL)
344
+ out << u16le(params.size)
345
+ params.each { |v| out << u16str("") << encode_param(v) }
346
+ out
347
+ end
348
+
349
+ def reserved5 = "\x00\x00\x00\x00\x00"
350
+
351
+ def encode_int(kind, value, min, max, pack_fmt)
352
+ raise Error.new("invalid_argument", "integer out of range") if value < min || value > max
353
+
354
+ (kind.chr + "\x00" + reserved5).b + [value].pack(pack_fmt)
355
+ end
356
+
357
+ def encode_uint(kind, value, max, pack_fmt)
358
+ raise Error.new("invalid_argument", "integer out of range") if value < 0 || value > max
359
+
360
+ (kind.chr + "\x00" + reserved5).b + [value].pack(pack_fmt)
361
+ end
362
+
363
+ def encode_enum(label, labels)
364
+ ord = labels.index(label)
365
+ raise Error.new("invalid_argument", "value is not a member of the ENUM label set") if ord.nil?
366
+
367
+ (KIND_ENUM.chr + "\x00" + reserved5).b + append_enum_labels(labels) + u16le(ord)
368
+ end
369
+
370
+ def encode_param(v)
371
+ case v
372
+ when nil
373
+ (KIND_STRING.chr + FLAG_NULL.chr + reserved5).b
374
+ when true, false
375
+ (KIND_BOOL.chr + "\x00" + reserved5 + (v ? "\x01" : "\x00")).b
376
+ when Integer
377
+ (KIND_DECIMAL.chr + "\x00" + reserved5).b + encode_decimal(v.to_s)
378
+ when BigDecimal
379
+ (KIND_DECIMAL.chr + "\x00" + reserved5).b + encode_decimal(v.to_s("F"))
380
+ when Float
381
+ raise Error.new("invalid_argument", "parameter is not finite") unless v.finite?
382
+
383
+ (KIND_DECIMAL.chr + "\x00" + reserved5).b + encode_decimal(format("%.17g", v))
384
+ when String
385
+ # A binary-encoded (ASCII-8BIT, e.g. via String#b) String is a BLOB;
386
+ # any other encoding (the UTF-8 default included) stays STRING.
387
+ kind = v.encoding == Encoding::ASCII_8BIT ? KIND_BLOB : KIND_STRING
388
+ (kind.chr + "\x00" + reserved5).b + u32bytes(v.b, MAX_PACKET)
389
+ when Time, DateTime
390
+ t = v.is_a?(DateTime) ? v.to_time : v
391
+ ns = (t.to_r * 1_000_000_000).to_i
392
+ (KIND_TIMESTAMPTZ.chr + "\x00" + reserved5).b + [ns].pack("q<")
393
+ when Date
394
+ # Checked after Time/DateTime above — DateTime is Date's own
395
+ # subclass in Ruby's stdlib, so only a bare Date reaches here.
396
+ day_count = (v - Date.new(1970, 1, 1)).to_i
397
+ (KIND_DATE.chr + "\x00" + reserved5).b + [day_count].pack("l<")
398
+ when NaiveTimestamp
399
+ # Treats v.value's own wall-clock fields as literal, ignoring
400
+ # whatever offset it carries — constructs a UTC Time from the same
401
+ # Y/M/D/H/M/S/usec fields rather than converting through the
402
+ # absolute instant, matching "the civil value read literally with
403
+ # no offset applied" (docs/design-datatypes.md D7). Converting
404
+ # through the absolute instant (t.to_r directly, as TimestampTZ
405
+ # does above) would be wrong here: a local-zoned Time's wall-clock
406
+ # reading is not its UTC epoch value.
407
+ t = v.value
408
+ t = t.to_time if t.is_a?(DateTime)
409
+ usec = (t.subsec * 1_000_000).to_i
410
+ utc_civil = Time.utc(t.year, t.month, t.day, t.hour, t.min, t.sec, usec)
411
+ ns = (utc_civil.to_r * 1_000_000_000).to_i
412
+ (KIND_TIMESTAMP.chr + "\x00" + reserved5).b + [ns].pack("q<")
413
+ when TimeOfDay
414
+ (KIND_TIME.chr + "\x00" + reserved5).b + [v.nanos_since_midnight].pack("Q<")
415
+ when Float32
416
+ # NaN/+-Infinity are valid FLOAT32/FLOAT64 values (unlike the bare
417
+ # Float -> Decimal path above, which requires finite) — the server
418
+ # canonicalizes -0.0 -> +0.0 and every NaN payload to one value
419
+ # (docs/design-datatypes.md D8).
420
+ (KIND_FLOAT32.chr + "\x00" + reserved5).b + [v.value].pack("e")
421
+ when Float64
422
+ (KIND_FLOAT64.chr + "\x00" + reserved5).b + [v.value].pack("E")
423
+ when Interval
424
+ (KIND_INTERVAL.chr + "\x00" + reserved5).b + [v.months, v.days].pack("l<l<") + [v.nanos].pack("q<")
425
+ when Int8
426
+ encode_int(KIND_INT8, v.value, -0x80, 0x7f, "c")
427
+ when Int16
428
+ encode_int(KIND_INT16, v.value, -0x8000, 0x7fff, "s<")
429
+ when Int32
430
+ encode_int(KIND_INT32, v.value, -0x80000000, 0x7fffffff, "l<")
431
+ when Int64
432
+ encode_int(KIND_INT64, v.value, -0x8000000000000000, 0x7fffffffffffffff, "q<")
433
+ when Uint8
434
+ encode_uint(KIND_UINT8, v.value, 0xff, "C")
435
+ when Uint16
436
+ encode_uint(KIND_UINT16, v.value, 0xffff, "S<")
437
+ when Uint32
438
+ encode_uint(KIND_UINT32, v.value, 0xffffffff, "L<")
439
+ when Uint64
440
+ encode_uint(KIND_UINT64, v.value, 0xffffffffffffffff, "Q<")
441
+ when EnumValue
442
+ encode_enum(v.value, v.labels)
443
+ when Point
444
+ (KIND_POINT.chr + "\x00" + reserved5).b + [v.lon, v.lat].pack("E2")
445
+ when Box
446
+ (KIND_BOX.chr + "\x00" + reserved5).b + [v.west, v.south, v.east, v.north].pack("E4")
447
+ when Vector
448
+ encode_vector(v)
449
+ when StructValue, MapValue
450
+ encode_collection_param(v)
451
+ when Geometry
452
+ wkt = v.wkt || v.to_wkt
453
+ wkt = "SRID=#{v.srid};#{wkt}" if v.srid && wkt !~ /\ASRID=/i
454
+ (KIND_STRING.chr + "\x00" + reserved5).b + u32bytes(wkt.b, MAX_PACKET)
455
+ when Array
456
+ if !v.empty? && v.all? { |x| x.is_a?(Numeric) }
457
+ encode_vector(Vector.new(dim: v.size, values: v.map(&:to_f)))
458
+ else
459
+ # A non-numeric (or empty) Array is an ARRAY collection param; the
460
+ # server re-coerces element types against the destination column.
461
+ encode_collection_param(v)
462
+ end
463
+ when Hash
464
+ json = JSON.generate(v)
465
+ (KIND_STRING.chr + "\x00" + reserved5).b + u32bytes(json.b, MAX_PACKET)
466
+ else
467
+ raise Error.new("invalid_argument", "unsupported parameter type: #{v.class}")
468
+ end
469
+ end
470
+
471
+ # --- Collections (STRUCT / ARRAY / MAP), docs/design-collections.md -------
472
+
473
+ def read_type_full(b, off, depth)
474
+ need!(b, off, 6, "type")
475
+ t = { kind: b.getbyte(off), precision: u16(b, off + 1), scale: u16(b, off + 3), elem: b.getbyte(off + 5) }
476
+ nxt = read_nested_descriptor(b, off + 6, t, depth)
477
+ [t, nxt]
478
+ end
479
+
480
+ def read_nested_descriptor(b, off, t, depth)
481
+ raise ProtocolError, "collection type nesting too deep" if depth > MAX_NEST_DEPTH + 1
482
+
483
+ case t[:kind]
484
+ when KIND_ENUM
485
+ labels, off = read_enum_labels(b, off)
486
+ t[:labels] = labels
487
+ off
488
+ when KIND_ARRAY
489
+ et, off = read_type_full(b, off, depth + 1)
490
+ t[:elem_type] = et
491
+ off
492
+ when KIND_MAP
493
+ kt, off = read_type_full(b, off, depth + 1)
494
+ vt, off = read_type_full(b, off, depth + 1)
495
+ t[:key_type] = kt
496
+ t[:elem_type] = vt
497
+ off
498
+ when KIND_STRUCT
499
+ need!(b, off, 2, "struct field count")
500
+ n = u16(b, off)
501
+ raise ProtocolError, "struct field count out of range" if n.zero? || n > MAX_STRUCT_FIELDS
502
+
503
+ off += 2
504
+ fields = []
505
+ n.times do
506
+ name, off = read_u16_string(b, off, 255)
507
+ ft, off = read_type_full(b, off, depth + 1)
508
+ fields << [name, ft]
509
+ end
510
+ t[:fields] = fields
511
+ off
512
+ else
513
+ off
514
+ end
515
+ end
516
+
517
+ def decode_payload(b, off, t)
518
+ kind = t[:kind]
519
+ return decode_collection_payload(b, off, t) if [KIND_STRUCT, KIND_ARRAY, KIND_MAP].include?(kind)
520
+
521
+ header = (kind.chr + "\x00" + reserved5).b
522
+ header << append_enum_labels(t[:labels] || []) if kind == KIND_ENUM
523
+ synthetic = header + b.byteslice(off, b.bytesize - off)
524
+ value, nxt, = decode_value(synthetic, 0)
525
+ [value, off + (nxt - header.bytesize)]
526
+ end
527
+
528
+ def decode_collection_payload(b, off, t)
529
+ need!(b, off, 4, "collection")
530
+ body_len = u32(b, off)
531
+ body_end = off + 4 + body_len
532
+ need!(b, off + 4, body_len, "collection body")
533
+ p = off + 4
534
+ n = u32(b, p)
535
+ p += 4
536
+ raise ProtocolError, "collection member count out of range" if n > (2 * MAX_COLLECTION_LEN) + 2 || n > body_len
537
+
538
+ nb = (n + 7) / 8
539
+ nulls = b.byteslice(p, nb)
540
+ p += nb
541
+ kind = t[:kind]
542
+ members = []
543
+ n.times do |i|
544
+ if (nulls.getbyte(i / 8) & (1 << (i % 8))) != 0
545
+ members << nil
546
+ next
547
+ end
548
+ mt = if kind == KIND_STRUCT
549
+ t[:fields][i][1]
550
+ elsif kind == KIND_ARRAY
551
+ t[:elem_type]
552
+ else
553
+ i.even? ? t[:key_type] : t[:elem_type]
554
+ end
555
+ value, p = decode_payload(b, p, mt)
556
+ members << value
557
+ end
558
+ case kind
559
+ when KIND_STRUCT
560
+ out = {}
561
+ t[:fields].each_with_index { |(name, _), i| out[name] = members[i] }
562
+ [out, body_end]
563
+ when KIND_ARRAY
564
+ [members, body_end]
565
+ else
566
+ out = {}
567
+ (0...(members.size - 1)).step(2) { |i| out[members[i]] = members[i + 1] }
568
+ [out, body_end]
569
+ end
570
+ end
571
+
572
+ def encode_type_full(t)
573
+ out = +(t[:kind].chr + reserved5).b
574
+ case t[:kind]
575
+ when KIND_ENUM
576
+ out << append_enum_labels(t[:labels] || [])
577
+ when KIND_ARRAY
578
+ out << encode_type_full(t[:elem_type])
579
+ when KIND_MAP
580
+ out << encode_type_full(t[:key_type]) << encode_type_full(t[:elem_type])
581
+ when KIND_STRUCT
582
+ out << u16le(t[:fields].size)
583
+ t[:fields].each { |name, ft| out << u16str(name, 255) << encode_type_full(ft) }
584
+ end
585
+ out
586
+ end
587
+
588
+ def infer_value(v)
589
+ case v
590
+ when nil
591
+ return [{ kind: KIND_STRING }, nil]
592
+ when StructValue
593
+ fields = []
594
+ payloads = []
595
+ v.fields.each do |name, fv|
596
+ ft, pl = infer_value(fv)
597
+ fields << [name.to_s, ft]
598
+ payloads << pl
599
+ end
600
+ return [{ kind: KIND_STRUCT, fields: fields }, collection_payload(payloads)]
601
+ when MapValue
602
+ items = v.entries.is_a?(Hash) ? v.entries.to_a : v.entries
603
+ types = []
604
+ payloads = []
605
+ items.each do |k, val|
606
+ kt, kp = infer_value(k)
607
+ vt, vp = infer_value(val)
608
+ types << kt << vt
609
+ payloads << kp << vp
610
+ end
611
+ ki = (0...payloads.size).step(2).find { |i| !payloads[i].nil? }
612
+ vi = (1...payloads.size).step(2).find { |i| !payloads[i].nil? }
613
+ key_type = ki ? types[ki] : { kind: KIND_STRING }
614
+ val_type = vi ? types[vi] : { kind: KIND_STRING }
615
+ return [{ kind: KIND_MAP, key_type: key_type, elem_type: val_type }, collection_payload(payloads)]
616
+ when Array
617
+ types = []
618
+ payloads = []
619
+ v.each do |x|
620
+ xt, xp = infer_value(x)
621
+ types << xt
622
+ payloads << xp
623
+ end
624
+ ei = payloads.index { |pl| !pl.nil? }
625
+ elem_type = ei ? types[ei] : { kind: KIND_STRING }
626
+ return [{ kind: KIND_ARRAY, elem_type: elem_type }, collection_payload(payloads)]
627
+ end
628
+ enc = encode_param(v)
629
+ kind = enc.getbyte(0)
630
+ hdr = 7
631
+ if kind == KIND_ENUM
632
+ lc = u16(enc, 7)
633
+ hdr = 9
634
+ lc.times { hdr += 2 + u16(enc, hdr) }
635
+ end
636
+ [{ kind: kind }, enc.byteslice(hdr, enc.bytesize - hdr)]
637
+ end
638
+
639
+ def collection_payload(payloads)
640
+ n = payloads.size
641
+ nb = (n + 7) / 8
642
+ nulls = Array.new(nb, 0)
643
+ chunks = +"".b
644
+ payloads.each_with_index do |pl, i|
645
+ if pl.nil?
646
+ nulls[i / 8] |= 1 << (i % 8)
647
+ else
648
+ chunks << pl
649
+ end
650
+ end
651
+ body = u32le(n) + nulls.pack("C*") + chunks
652
+ u32le(body.bytesize) + body
653
+ end
654
+
655
+ def encode_collection_param(v)
656
+ t, payload = infer_value(v)
657
+ full = encode_type_full(t)
658
+ type_body = full.byteslice(1, full.bytesize - 1)
659
+ (t[:kind].chr + "\x00").b + type_body + (payload || "".b)
660
+ end
661
+
662
+ # --- Spatial: EWKB decode (Spatial track, docs/design-spatial.md) ------
663
+
664
+ EWKB_TYPES = {
665
+ 1 => "Point", 2 => "LineString", 3 => "Polygon",
666
+ 4 => "MultiPoint", 5 => "MultiLineString", 6 => "MultiPolygon",
667
+ 7 => "GeometryCollection"
668
+ }.freeze
669
+ EWKB_SRID_FLAG = 0x20000000
670
+
671
+ def decode_ewkb(b, off, depth)
672
+ raise ProtocolError, "geometry nesting too deep" if depth > 8
673
+
674
+ need!(b, off, 5, "geometry header")
675
+ raise ProtocolError, "only little-endian EWKB is supported" unless b.getbyte(off) == 1
676
+
677
+ tword = u32(b, off + 1)
678
+ gtype = tword & ~EWKB_SRID_FLAG
679
+ p = off + 5
680
+ srid = 0
681
+ if (tword & EWKB_SRID_FLAG) != 0
682
+ srid = u32(b, p)
683
+ p += 4
684
+ end
685
+ name = EWKB_TYPES[gtype]
686
+ raise ProtocolError, "unknown geometry type" unless name
687
+
688
+ f64 = lambda {
689
+ v = b.byteslice(p, 8).unpack1("E")
690
+ p += 8
691
+ v
692
+ }
693
+ u32f = lambda {
694
+ v = u32(b, p)
695
+ p += 4
696
+ v
697
+ }
698
+ pts = ->(n) { Array.new(n) { [f64.call, f64.call] } }
699
+
700
+ case gtype
701
+ when 1
702
+ [Geometry.new(type: name, srid: srid, coordinates: [f64.call, f64.call]), p]
703
+ when 2
704
+ [Geometry.new(type: name, srid: srid, coordinates: pts.call(u32f.call)), p]
705
+ when 3
706
+ nr = u32f.call
707
+ rings = Array.new(nr) { pts.call(u32f.call) }
708
+ [Geometry.new(type: name, srid: srid, coordinates: rings), p]
709
+ else
710
+ np = u32f.call
711
+ parts = []
712
+ np.times do
713
+ sub, p = decode_ewkb(b, p, depth + 1)
714
+ parts << sub
715
+ end
716
+ if gtype == 7
717
+ [Geometry.new(type: name, srid: srid, geometries: parts), p]
718
+ else
719
+ [Geometry.new(type: name, srid: srid, coordinates: parts.map(&:coordinates)), p]
720
+ end
721
+ end
722
+ end
723
+
724
+ def decode_value(b, off)
725
+ need!(b, off, 7, "value header")
726
+ kind = b.getbyte(off)
727
+ flags = b.getbyte(off + 1)
728
+ off += 7
729
+ enum_labels = nil
730
+ coll_type = nil
731
+ if kind == KIND_ENUM
732
+ enum_labels, off = read_enum_labels(b, off)
733
+ elsif [KIND_STRUCT, KIND_ARRAY, KIND_MAP].include?(kind)
734
+ coll_type = { kind: kind }
735
+ off = read_nested_descriptor(b, off, coll_type, 0)
736
+ end
737
+ return [nil, off, kind] if flags & FLAG_NULL != 0
738
+
739
+ if [KIND_STRUCT, KIND_ARRAY, KIND_MAP].include?(kind)
740
+ value, nxt = decode_collection_payload(b, off, coll_type)
741
+ return [value, nxt, kind]
742
+ end
743
+
744
+ case kind
745
+ when KIND_ENUM
746
+ need!(b, off, 2, "enum")
747
+ ord = u16(b, off)
748
+ raise ProtocolError, "ENUM ordinal out of range" if ord >= enum_labels.size
749
+
750
+ [enum_labels[ord], off + 2, kind]
751
+ when KIND_UUID
752
+ need!(b, off, 16, "uuid")
753
+ [format_uuid(b.byteslice(off, 16)), off + 16, kind]
754
+ when KIND_STRING, KIND_TEXT, KIND_CHAR, KIND_VARCHAR
755
+ raw, next_off = read_u32_bytes(b, off, MAX_PACKET)
756
+ [raw.dup.force_encoding("UTF-8"), next_off, kind]
757
+ when KIND_BLOB
758
+ raw, next_off = read_u32_bytes(b, off, MAX_PACKET)
759
+ [raw.dup.force_encoding("ASCII-8BIT"), next_off, kind]
760
+ when KIND_JSON
761
+ raw, next_off = read_u32_bytes(b, off, MAX_PACKET)
762
+ [decode_nsjb(raw), next_off, kind]
763
+ when KIND_DECIMAL
764
+ raw, next_off = read_u32_bytes(b, off, MAX_PACKET)
765
+ [decode_decimal(raw), next_off, kind]
766
+ when KIND_TIMESTAMPTZ
767
+ ns = i64(b, off)
768
+ sec, nsec = ns.divmod(1_000_000_000)
769
+ [Time.at(sec, nsec, :nanosecond, in: "UTC"), off + 8, kind]
770
+ when KIND_TIMESTAMP
771
+ # Naive/no-timezone: same wire shape as TimestampTZ. Ruby has no
772
+ # distinct naive-time type, so this returns a UTC-tagged Time whose
773
+ # fields are the intended civil value — same convention as
774
+ # TimestampTZ's own decode above, just carrying no real zone
775
+ # information (docs/design-datatypes.md D7).
776
+ ns = i64(b, off)
777
+ sec, nsec = ns.divmod(1_000_000_000)
778
+ [Time.at(sec, nsec, :nanosecond, in: "UTC"), off + 8, kind]
779
+ when KIND_DATE
780
+ need!(b, off, 4, "date")
781
+ day_count = b.byteslice(off, 4).unpack1("l<")
782
+ [Date.new(1970, 1, 1) + day_count, off + 4, kind]
783
+ when KIND_TIME
784
+ need!(b, off, 8, "time")
785
+ [u64(b, off), off + 8, kind]
786
+ when KIND_FLOAT32
787
+ need!(b, off, 4, "float32")
788
+ [b.byteslice(off, 4).unpack1("e"), off + 4, kind]
789
+ when KIND_FLOAT64
790
+ need!(b, off, 8, "float64")
791
+ [b.byteslice(off, 8).unpack1("E"), off + 8, kind]
792
+ when KIND_INTERVAL
793
+ need!(b, off, 16, "interval")
794
+ months, days = b.byteslice(off, 8).unpack("l<l<")
795
+ nanos = b.byteslice(off + 8, 8).unpack1("q<")
796
+ [Interval.new(months: months, days: days, nanos: nanos), off + 16, kind]
797
+ when KIND_BOOL
798
+ need!(b, off, 1, "bool")
799
+ [b.getbyte(off) != 0, off + 1, kind]
800
+ when KIND_INT8
801
+ need!(b, off, 1, "int8")
802
+ [b.byteslice(off, 1).unpack1("c"), off + 1, kind]
803
+ when KIND_INT16
804
+ need!(b, off, 2, "int16")
805
+ [b.byteslice(off, 2).unpack1("s<"), off + 2, kind]
806
+ when KIND_INT32
807
+ need!(b, off, 4, "int32")
808
+ [b.byteslice(off, 4).unpack1("l<"), off + 4, kind]
809
+ when KIND_INT64
810
+ need!(b, off, 8, "int64")
811
+ [b.byteslice(off, 8).unpack1("q<"), off + 8, kind]
812
+ when KIND_UINT8
813
+ need!(b, off, 1, "uint8")
814
+ [b.getbyte(off), off + 1, kind]
815
+ when KIND_UINT16
816
+ need!(b, off, 2, "uint16")
817
+ [b.byteslice(off, 2).unpack1("S<"), off + 2, kind]
818
+ when KIND_UINT32
819
+ need!(b, off, 4, "uint32")
820
+ [b.byteslice(off, 4).unpack1("L<"), off + 4, kind]
821
+ when KIND_UINT64
822
+ need!(b, off, 8, "uint64")
823
+ [b.byteslice(off, 8).unpack1("Q<"), off + 8, kind]
824
+ when KIND_VECTOR
825
+ value, next_off = decode_vector(b, off)
826
+ [value, next_off, kind]
827
+ when KIND_POINT
828
+ need!(b, off, 16, "point")
829
+ lon, lat = b.byteslice(off, 16).unpack("E2")
830
+ [Point.new(lon, lat), off + 16, kind]
831
+ when KIND_BOX
832
+ need!(b, off, 32, "box")
833
+ w, s, e, n = b.byteslice(off, 32).unpack("E4")
834
+ [Box.new(w, s, e, n), off + 32, kind]
835
+ when KIND_LINE
836
+ n = u16(b, off)
837
+ p = off + 2
838
+ coords = []
839
+ (n * 2).times do
840
+ need!(b, p, 8, "line coord")
841
+ coords << b.byteslice(p, 8).unpack1("E")
842
+ p += 8
843
+ end
844
+ [Line.new(coords), p, kind]
845
+ when KIND_GEOMETRY, KIND_GEOGRAPHY
846
+ len = u32(b, off)
847
+ g, = decode_ewkb(b, off + 4, 0)
848
+ [g, off + 4 + len, kind]
849
+ when KIND_POLYGON
850
+ nr = u16(b, off)
851
+ p = off + 2
852
+ rings = []
853
+ nr.times do
854
+ npts = u16(b, p)
855
+ p += 2
856
+ ring = []
857
+ (npts * 2).times do
858
+ need!(b, p, 8, "polygon coord")
859
+ ring << b.byteslice(p, 8).unpack1("E")
860
+ p += 8
861
+ end
862
+ rings << ring
863
+ end
864
+ [Polygon.new(rings), p, kind]
865
+ else
866
+ raise ProtocolError, "unsupported type"
867
+ end
868
+ end
869
+
870
+ def decode_vector(b, off)
871
+ dim = u16(b, off)
872
+ flag = b.getbyte(off + 2)
873
+ if flag & 1 != 0
874
+ return [Vector.new(dim: dim, ref: true), off + 3]
875
+ end
876
+ if flag & 2 != 0
877
+ need!(b, off + 3, 4, "sparse nnz")
878
+ nnz = u32(b, off + 3)
879
+ indices = []
880
+ values = []
881
+ p = off + 7
882
+ nnz.times do
883
+ need!(b, p, 8, "sparse entry")
884
+ indices << u32(b, p)
885
+ values << b.byteslice(p + 4, 4).unpack1("e")
886
+ p += 8
887
+ end
888
+ return [Vector.new(dim: dim, values: values, indices: indices), p]
889
+ end
890
+ p = off + 3
891
+ values = []
892
+ dim.times do
893
+ need!(b, p, 4, "vector component")
894
+ values << b.byteslice(p, 4).unpack1("e")
895
+ p += 4
896
+ end
897
+ [Vector.new(dim: dim, values: values), p]
898
+ end
899
+
900
+ def decode_row_desc(b)
901
+ n = u16(b, 0)
902
+ off = 2
903
+ cols = []
904
+ n.times do
905
+ name, off2 = read_u16_string(b, off, MAX_NAME)
906
+ off = off2
907
+ need!(b, off, 6, "column type")
908
+ kind = b.getbyte(off)
909
+ off += 6
910
+ labels = nil
911
+ coll_type = nil
912
+ if kind == KIND_ENUM
913
+ labels, off = read_enum_labels(b, off)
914
+ elsif [KIND_STRUCT, KIND_ARRAY, KIND_MAP].include?(kind)
915
+ coll_type = { kind: kind }
916
+ off = read_nested_descriptor(b, off, coll_type, 0)
917
+ end
918
+ cols << Column.new(name, kind, labels, coll_type)
919
+ end
920
+ cols
921
+ end
922
+
923
+ def decode_data_batch(b)
924
+ nrows = u32(b, 0)
925
+ off = 4
926
+ rows = []
927
+ nrows.times do
928
+ ncols = u16(b, off)
929
+ off += 2
930
+ row = []
931
+ ncols.times do
932
+ value, next_off, = decode_value(b, off)
933
+ row << value
934
+ off = next_off
935
+ end
936
+ rows << row
937
+ end
938
+ rows
939
+ end
940
+
941
+ def decode_command_complete(b)
942
+ raise ProtocolError, "bad command-complete length" unless b.bytesize == 8
943
+
944
+ u64(b, 0)
945
+ end
946
+
947
+ def decode_error(b)
948
+ code, off = read_u16_string(b, 0, MAX_NAME)
949
+ msg, = read_u16_string(b, off, MAX_NAME)
950
+ Error.new(code, msg)
951
+ end
952
+
953
+ def encode_set_read_consistency(mode, max_staleness_ms)
954
+ raise Error.new("invalid_argument", "unknown read consistency mode") unless [READ_STRONG, READ_BOUNDED,
955
+ READ_STALE].include?(mode)
956
+
957
+ ms = max_staleness_ms.positive? ? max_staleness_ms : 0
958
+ mode.chr + u64le(ms)
959
+ end
960
+
961
+ def decode_node_status(b)
962
+ role, off = read_u16_string(b, 0, MAX_NAME)
963
+ raise ProtocolError, "bad node-status length" unless b.bytesize - off == 25
964
+
965
+ flags = b.getbyte(off)
966
+ off += 1
967
+ applied_lsn = u64(b, off)
968
+ raw_contact = u64(b, off + 8)
969
+ last_contact_ms = raw_contact == 0xFFFFFFFFFFFFFFFF ? -1 : raw_contact
970
+ apply_backlog = u64(b, off + 16)
971
+ NodeStatus.new(role, flags & 1 != 0, flags & 2 != 0, applied_lsn, last_contact_ms, apply_backlog)
972
+ end
973
+
974
+ DECIMAL_RE = /\A\d+(\.\d+)?\z/.freeze
975
+
976
+ def encode_decimal(s)
977
+ s = s.strip
978
+ neg = false
979
+ s = s[1..] if s.start_with?("+")
980
+ if s.start_with?("-")
981
+ neg = true
982
+ s = s[1..]
983
+ end
984
+ raise Error.new("invalid_argument", "invalid decimal") unless DECIMAL_RE.match?(s)
985
+
986
+ int_part, frac_part = s.split(".", 2)
987
+ frac_part ||= ""
988
+ scale = frac_part.length
989
+ digits = (int_part + frac_part).sub(/\A0+(?=\d)/, "")
990
+ coef = dec_to_bytes(digits)
991
+ body = (neg ? "\x01" : "\x00").b + "\x00".b + u16le(scale) + coef
992
+ u32bytes(body, MAX_PACKET)
993
+ end
994
+
995
+ def decode_decimal(body)
996
+ raise Error.new("invalid_format", "truncated decimal") if body.bytesize < 4
997
+
998
+ neg = body.getbyte(0) & 1 != 0
999
+ scale = u16(body, 2)
1000
+ digits = bytes_to_dec(body.byteslice(4, body.bytesize - 4))
1001
+ s = if scale.positive?
1002
+ digits = digits.rjust(scale + 1, "0")
1003
+ "#{digits[0..-scale - 1]}.#{digits[-scale..]}"
1004
+ else
1005
+ digits
1006
+ end
1007
+ s = "-#{s}" if neg && !(s == "0" || s.match?(/\A0\.0+\z/))
1008
+ BigDecimal(s)
1009
+ end
1010
+
1011
+ def dec_to_bytes(digits)
1012
+ n = digits.to_i
1013
+ return "".b if n.zero?
1014
+
1015
+ nbytes = (n.bit_length + 7) / 8
1016
+ out = +"".b
1017
+ nbytes.times { |i| out.prepend(((n >> (8 * i)) & 0xFF).chr) }
1018
+ out
1019
+ end
1020
+
1021
+ def bytes_to_dec(raw)
1022
+ n = 0
1023
+ raw.each_byte { |byte| n = (n * 256) + byte }
1024
+ n.to_s
1025
+ end
1026
+
1027
+ def format_uuid(raw)
1028
+ hex = raw.unpack1("H*")
1029
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
1030
+ end
1031
+
1032
+ def encode_vector(v)
1033
+ dim = v.dim && v.dim.positive? ? v.dim : v.values.size
1034
+ header = (KIND_VECTOR.chr + "\x00" + reserved5).b
1035
+ if v.indices
1036
+ payload = +(u16le(dim) + "\x02")
1037
+ payload << u32le(v.indices.size)
1038
+ v.indices.each_with_index do |idx, i|
1039
+ payload << u32le(idx)
1040
+ payload << [v.values[i]].pack("e")
1041
+ end
1042
+ return header + payload
1043
+ end
1044
+ payload = +(u16le(dim) + "\x00")
1045
+ v.values.each { |val| payload << [val].pack("e") }
1046
+ header + payload
1047
+ end
1048
+
1049
+ # --- NSJB binary JSON (see internal/protocol/messages.go EncodeJSON) ---
1050
+
1051
+ def decode_nsjb(doc)
1052
+ raise Error.new("invalid_format", "not binary JSON") if doc.bytesize < 5 || doc.byteslice(0,
1053
+ 4) != "NSJB" || doc.getbyte(4) != 1
1054
+
1055
+ value, next_off = read_nsjb(doc, 5)
1056
+ raise Error.new("invalid_format", "trailing JSON bytes") unless next_off == doc.bytesize
1057
+
1058
+ value
1059
+ end
1060
+
1061
+ def read_nsjb(b, off)
1062
+ raise Error.new("invalid_format", "truncated JSON") if off >= b.bytesize
1063
+
1064
+ tag = b.getbyte(off)
1065
+ case tag
1066
+ when 0x00 then [nil, off + 1]
1067
+ when 0x01 then [false, off + 1]
1068
+ when 0x02 then [true, off + 1]
1069
+ when 0x03 then [i64(b, off + 1), off + 9]
1070
+ when 0x04 then read_nsjb_str(b, off, false)
1071
+ when 0x05 then read_nsjb_str(b, off, true)
1072
+ when 0x06 then read_nsjb_array(b, off)
1073
+ when 0x07 then read_nsjb_object(b, off)
1074
+ else raise Error.new("invalid_format", "unknown JSON tag")
1075
+ end
1076
+ end
1077
+
1078
+ def read_nsjb_str(b, off, number)
1079
+ n = u32(b, off + 1)
1080
+ end_off = off + 5 + n
1081
+ s = b.byteslice(off + 5, n).force_encoding("UTF-8")
1082
+ if number
1083
+ if /\A-?\d+\z/.match?(s)
1084
+ return [s.to_i, end_off]
1085
+ elsif /\A-?\d+\.\d+\z/.match?(s)
1086
+ return [s.to_f, end_off]
1087
+ end
1088
+ end
1089
+ [s, end_off]
1090
+ end
1091
+
1092
+ def read_nsjb_array(b, off)
1093
+ size = u32(b, off + 1)
1094
+ body = off + 5
1095
+ end_off = body + size
1096
+ count = u32(b, body)
1097
+ cur = body + 4
1098
+ out = []
1099
+ count.times do
1100
+ v, cur2 = read_nsjb(b, cur)
1101
+ cur = cur2
1102
+ out << v
1103
+ end
1104
+ [out, end_off]
1105
+ end
1106
+
1107
+ def read_nsjb_object(b, off)
1108
+ size = u32(b, off + 1)
1109
+ body = off + 5
1110
+ end_off = body + size
1111
+ count = u16(b, body)
1112
+ cur = body + 2
1113
+ out = {}
1114
+ count.times do
1115
+ klen = u16(b, cur)
1116
+ cur += 2
1117
+ key = b.byteslice(cur, klen).force_encoding("UTF-8")
1118
+ cur += klen
1119
+ v, cur2 = read_nsjb(b, cur)
1120
+ cur = cur2
1121
+ out[key] = v
1122
+ end
1123
+ [out, end_off]
1124
+ end
1125
+ end
1126
+ end