@powersync/service-module-mongodb 0.15.3 → 0.16.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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/api/MongoRouteAPIAdapter.js +2 -2
  3. package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
  4. package/dist/replication/ChangeStream.d.ts +6 -6
  5. package/dist/replication/ChangeStream.js +300 -322
  6. package/dist/replication/ChangeStream.js.map +1 -1
  7. package/dist/replication/ChangeStreamReplicationJob.js +2 -2
  8. package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
  9. package/dist/replication/ChangeStreamReplicator.d.ts +1 -1
  10. package/dist/replication/ChangeStreamReplicator.js +1 -1
  11. package/dist/replication/ChangeStreamReplicator.js.map +1 -1
  12. package/dist/replication/JsonBufferWriter.d.ts +80 -0
  13. package/dist/replication/JsonBufferWriter.js +342 -0
  14. package/dist/replication/JsonBufferWriter.js.map +1 -0
  15. package/dist/replication/MongoManager.d.ts +1 -1
  16. package/dist/replication/MongoManager.js +1 -1
  17. package/dist/replication/MongoManager.js.map +1 -1
  18. package/dist/replication/MongoRelation.js +4 -0
  19. package/dist/replication/MongoRelation.js.map +1 -1
  20. package/dist/replication/MongoSnapshotQuery.d.ts +1 -1
  21. package/dist/replication/MongoSnapshotQuery.js +6 -3
  22. package/dist/replication/MongoSnapshotQuery.js.map +1 -1
  23. package/dist/replication/RawChangeStream.d.ts +55 -0
  24. package/dist/replication/RawChangeStream.js +322 -0
  25. package/dist/replication/RawChangeStream.js.map +1 -0
  26. package/dist/replication/SourceRowConverter.d.ts +46 -0
  27. package/dist/replication/SourceRowConverter.js +42 -0
  28. package/dist/replication/SourceRowConverter.js.map +1 -0
  29. package/dist/replication/bufferToSqlite.d.ts +43 -0
  30. package/dist/replication/bufferToSqlite.js +740 -0
  31. package/dist/replication/bufferToSqlite.js.map +1 -0
  32. package/dist/replication/internal-mongodb-utils.d.ts +0 -12
  33. package/dist/replication/internal-mongodb-utils.js +0 -54
  34. package/dist/replication/internal-mongodb-utils.js.map +1 -1
  35. package/dist/replication/replication-index.d.ts +4 -2
  36. package/dist/replication/replication-index.js +4 -2
  37. package/dist/replication/replication-index.js.map +1 -1
  38. package/dist/replication/replication-utils.d.ts +1 -1
  39. package/dist/types/types.js.map +1 -1
  40. package/package.json +11 -11
  41. package/scripts/benchmark-change-document-json.mts +358 -0
  42. package/scripts/benchmark-change-document.mts +370 -0
  43. package/src/api/MongoRouteAPIAdapter.ts +2 -2
  44. package/src/replication/ChangeStream.ts +348 -371
  45. package/src/replication/ChangeStreamReplicationJob.ts +2 -2
  46. package/src/replication/ChangeStreamReplicator.ts +2 -5
  47. package/src/replication/JsonBufferWriter.ts +390 -0
  48. package/src/replication/MongoManager.ts +2 -2
  49. package/src/replication/MongoRelation.ts +5 -2
  50. package/src/replication/MongoSnapshotQuery.ts +8 -5
  51. package/src/replication/RawChangeStream.ts +460 -0
  52. package/src/replication/SourceRowConverter.ts +65 -0
  53. package/src/replication/bufferToSqlite.ts +944 -0
  54. package/src/replication/internal-mongodb-utils.ts +0 -66
  55. package/src/replication/replication-index.ts +4 -2
  56. package/src/replication/replication-utils.ts +2 -2
  57. package/src/types/types.ts +1 -1
  58. package/test/src/buffer_to_sqlite.test.ts +1146 -0
  59. package/test/src/change_stream.test.ts +49 -3
  60. package/test/src/change_stream_utils.ts +4 -10
  61. package/test/src/mongo_test.test.ts +66 -64
  62. package/test/src/parse_document_id.test.ts +54 -0
  63. package/test/src/raw_change_stream.test.ts +547 -0
  64. package/test/src/resume.test.ts +12 -2
  65. package/test/src/util.ts +56 -3
  66. package/test/tsconfig.json +0 -1
  67. package/tsconfig.scripts.json +13 -0
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/test/src/internal_mongodb_utils.test.ts +0 -103
@@ -0,0 +1,1146 @@
1
+ import {
2
+ CompatibilityContext,
3
+ CompatibilityEdition,
4
+ CompatibilityOption,
5
+ TimeValuePrecision
6
+ } from '@powersync/service-sync-rules';
7
+ import {
8
+ Binary,
9
+ BSON,
10
+ BSONRegExp,
11
+ BSONSymbol,
12
+ Code,
13
+ DBRef,
14
+ Decimal128,
15
+ Double,
16
+ Int32,
17
+ Long,
18
+ MaxKey,
19
+ MinKey,
20
+ ObjectId,
21
+ Timestamp
22
+ } from 'bson';
23
+ import { describe, expect, test } from 'vitest';
24
+
25
+ import { JsonBufferWriter } from '@module/replication/JsonBufferWriter.js';
26
+ import {
27
+ DirectSourceRowConverter,
28
+ LegacySourceRowConverter,
29
+ SourceRowConverter
30
+ } from '@module/replication/SourceRowConverter.js';
31
+
32
+ type Placement = 'top' | 'array' | 'nested';
33
+ type ExpectedPlacements = Record<Placement, unknown>;
34
+ type RowCapture = { ok: true; row: unknown } | { ok: false; message: string };
35
+ type OutputCapture = { ok: true; output: unknown } | { ok: false; message: string };
36
+ type ConverterCase = {
37
+ name: string;
38
+ buildBuffer: (placement: Placement) => Buffer;
39
+ expected: ExpectedPlacements;
40
+ context?: CompatibilityContext;
41
+ expectedId?: (placement: Placement) => string;
42
+ };
43
+
44
+ // Serialization differs in cases between top-level values, values in arrays and values in nested documents.
45
+ // We test each one.
46
+ const PLACEMENTS: Placement[] = ['top', 'array', 'nested'];
47
+ const CONTEXT = CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY;
48
+ const legacyConverter = new LegacySourceRowConverter(CONTEXT);
49
+ const directConverter = new DirectSourceRowConverter(CONTEXT);
50
+
51
+ const normalDate = new Date('2023-03-06T13:47:00.000Z');
52
+ const positiveExtendedDate = new Date(253402300800000);
53
+ const negativeExtendedDate = new Date(-62167219200001);
54
+ const objectId = new ObjectId('66e834cc91d805df11fa0ecb');
55
+ const uuidBytes = Buffer.from('00112233445566778899aabbccddeeff', 'hex');
56
+ const depth21Expected =
57
+ '{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":{"nested":1}}}}}}}}}}}}}}}}}}}}}';
58
+
59
+ describe('JsonBufferWriter', () => {
60
+ test('writeQuotedUtf8Slice escapes large control-heavy payloads without corruption', () => {
61
+ const writer = new JsonBufferWriter(32);
62
+ const payload = Buffer.alloc(256, 0x01);
63
+
64
+ writer.writeQuotedUtf8Slice(payload, 0, payload.length);
65
+
66
+ const expected = JSON.stringify(payload.toString('latin1'));
67
+ expect(writer.toString()).toBe(expected);
68
+ });
69
+
70
+ test('writeQuotedUtf8Slice escapes large control-heavy payloads without corruption (2)', () => {
71
+ const writer = new JsonBufferWriter(32);
72
+ const payload1 = Buffer.alloc(512);
73
+ const payload2 = Buffer.alloc(256, 0x01);
74
+
75
+ // This makes sure reset() properly clears the previous data
76
+ writer.writeQuotedUtf8Slice(payload1, 0, payload1.length);
77
+ writer.reset();
78
+ writer.writeQuotedUtf8Slice(payload2, 0, payload2.length);
79
+
80
+ const expected = JSON.stringify(payload2.toString('latin1'));
81
+ expect(writer.toString()).toBe(expected);
82
+ });
83
+
84
+ test('truncate clears discarded bytes', () => {
85
+ const writer = new JsonBufferWriter(32);
86
+
87
+ writer.writeAscii('discarded');
88
+ writer.truncate(0);
89
+
90
+ const { buffer } = writer as unknown as { buffer: Buffer };
91
+ expect(buffer.subarray(0, 'discarded'.length)).toEqual(Buffer.alloc('discarded'.length));
92
+ });
93
+ });
94
+
95
+ const testCases: ConverterCase[] = [
96
+ serializableCase('double', new Double(1.25), placements(1.25, '[1.25]', '{"nested":1.25}')),
97
+ serializableCase('double:nan', new Double(NaN), placements(NaN, '[null]', '{"nested":null}')),
98
+ serializableCase('double:infinity', new Double(Infinity), placements(Infinity, '[null]', '{"nested":null}')),
99
+ serializableCase(
100
+ 'double:negativeInfinity',
101
+ new Double(-Infinity),
102
+ placements(-Infinity, '[null]', '{"nested":null}')
103
+ ),
104
+ serializableCase(
105
+ 'string',
106
+ 'line 1\nline "2" \\ snowman ☃ \u0001',
107
+ jsonStringPlacements('line 1\nline "2" \\ snowman ☃ \u0001')
108
+ ),
109
+ serializableCase(
110
+ 'string:surrogate-pair',
111
+ 'emoji: \ud83d\ude00 rocket: \ud83d\ude80',
112
+ jsonStringPlacements('emoji: \ud83d\ude00 rocket: \ud83d\ude80')
113
+ ),
114
+ // This one is not quite valid utf-8 - it becomes U+FFFD.
115
+ serializableCase('string:half-surrogate-pair', 'foo: \ud83d"', jsonStringPlacements('foo: \ufffd"')),
116
+ serializableCase(
117
+ 'document',
118
+ { alpha: 1, bravo: 'two', charlie: true, delta: null },
119
+ jsonTextPlacements('{"alpha":1,"bravo":"two","charlie":1,"delta":null}')
120
+ ),
121
+ serializableCase('array', [1, 'two', false, null, { deep: 3 }], jsonTextPlacements('[1,"two",0,null,{"deep":3}]')),
122
+ serializableCase('objectId', objectId, jsonStringPlacements('66e834cc91d805df11fa0ecb')),
123
+ serializableCase('bool', true, placements(1n, '[1]', '{"nested":1}')),
124
+ serializableCase('date', normalDate, jsonStringPlacements('2023-03-06 13:47:00.000Z')),
125
+ serializableCase('date:+010000', positiveExtendedDate, jsonStringPlacements('+010000-01-01 00:00:00.000Z')),
126
+ serializableCase('date:-000001', negativeExtendedDate, jsonStringPlacements('-000001-12-31 23:59:59.999Z')),
127
+ serializableCase('null', null, placements(null, '[null]', '{"nested":null}')),
128
+ serializableCase('regex:flags:i', new BSONRegExp('a', 'i'), jsonTextPlacements('{"pattern":"a","options":"i"}')),
129
+ serializableCase('regex:flags:m', new BSONRegExp('a', 'm'), jsonTextPlacements('{"pattern":"a","options":"m"}')),
130
+ rawCase('undefined', 0x06, Buffer.alloc(0), placements(null, '[null]', '{}')),
131
+ serializableCase('code', new Code('return 1;'), jsonTextPlacements('{"code":"return 1;","scope":null}')),
132
+ serializableCase('symbol', new BSONSymbol('sym'), jsonStringPlacements('sym')),
133
+ rawCase(
134
+ 'dbpointer',
135
+ 0x0c,
136
+ Buffer.concat([bsonString('mycollection'), Buffer.from('66e834cc91d805df11fa0ecb', 'hex')]),
137
+ jsonTextPlacements('{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}')
138
+ ),
139
+ rawCase(
140
+ 'dbpointer:empty-collection',
141
+ 0x0c,
142
+ Buffer.concat([bsonString(''), Buffer.from('66e834cc91d805df11fa0ecb', 'hex')]),
143
+ jsonTextPlacements('{"collection":"","oid":"66e834cc91d805df11fa0ecb","fields":{}}')
144
+ ),
145
+ rawCase(
146
+ 'dbpointer:escaped-collection',
147
+ 0x0c,
148
+ Buffer.concat([bsonString('my_\\"coll\\"\\\\name/☃'), Buffer.from('66e834cc91d805df11fa0ecb', 'hex')]),
149
+ jsonTextPlacements(
150
+ '{"collection":"my_\\\\\\"coll\\\\\\"\\\\\\\\name/☃","oid":"66e834cc91d805df11fa0ecb","fields":{}}'
151
+ )
152
+ ),
153
+ rawCase(
154
+ 'dbpointer:different-oid',
155
+ 0x0c,
156
+ Buffer.concat([bsonString('other'), Buffer.from('00112233445566778899aabb', 'hex')]),
157
+ jsonTextPlacements('{"collection":"other","oid":"00112233445566778899aabb","fields":{}}')
158
+ ),
159
+ rawCase(
160
+ 'dbpointer:with-db',
161
+ 0x0c,
162
+ Buffer.concat([bsonString('mydb.mycollection'), Buffer.from('66e834cc91d805df11fa0ecb', 'hex')]),
163
+ jsonTextPlacements('{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","db":"mydb","fields":{}}')
164
+ ),
165
+ serializableCase(
166
+ 'codeScope',
167
+ new Code('return x;', { x: 1 }),
168
+ jsonTextPlacements('{"code":"return x;","scope":{"x":1}}')
169
+ ),
170
+ serializableCase('int32', new Int32(123), placements(123n, '[123]', '{"nested":123}')),
171
+ serializableCase(
172
+ 'timestamp',
173
+ Timestamp.fromBits(123, 456),
174
+ placements(1958505087099n, '[1958505087099]', '{"nested":1958505087099}')
175
+ ),
176
+ serializableCase(
177
+ 'int64',
178
+ Long.fromBigInt(9007199254740993n),
179
+ placements(9007199254740993n, '[9007199254740993]', '{"nested":9007199254740993}')
180
+ ),
181
+ serializableCase('decimal128', Decimal128.fromString('1234.5678'), jsonStringPlacements('1234.5678')),
182
+ serializableCase('minKey', new MinKey(), placements(null, '[null]', '{"nested":null}')),
183
+ serializableCase('maxKey', new MaxKey(), placements(null, '[null]', '{"nested":null}')),
184
+ serializableCase(
185
+ 'binary:default',
186
+ new Binary(Buffer.from([0, 1, 2, 255]), Binary.SUBTYPE_DEFAULT),
187
+ placements(new Uint8Array([0, 1, 2, 255]), '[null]', '{}')
188
+ ),
189
+ serializableCase(
190
+ 'binary:function',
191
+ new Binary(Buffer.from([1, 2, 3]), Binary.SUBTYPE_FUNCTION),
192
+ placements(new Uint8Array([1, 2, 3]), '[null]', '{}')
193
+ ),
194
+ serializableCase(
195
+ 'binary:byteArray',
196
+ new Binary(Buffer.from([4, 5, 6]), Binary.SUBTYPE_BYTE_ARRAY),
197
+ placements(new Uint8Array([4, 5, 6]), '[null]', '{}')
198
+ ),
199
+ serializableCase(
200
+ 'binary:uuidOld',
201
+ new Binary(uuidBytes, Binary.SUBTYPE_UUID_OLD),
202
+ placements(new Uint8Array(uuidBytes), '[null]', '{}')
203
+ ),
204
+ serializableCase(
205
+ 'binary:uuid',
206
+ new Binary(uuidBytes, Binary.SUBTYPE_UUID),
207
+ jsonStringPlacements('00112233-4455-6677-8899-aabbccddeeff')
208
+ ),
209
+ serializableCase(
210
+ 'binary:md5',
211
+ new Binary(Buffer.from([7, 8, 9]), Binary.SUBTYPE_MD5),
212
+ placements(new Uint8Array([7, 8, 9]), '[null]', '{}')
213
+ ),
214
+ serializableCase(
215
+ 'binary:encrypted',
216
+ new Binary(Buffer.from([10, 11, 12]), Binary.SUBTYPE_ENCRYPTED),
217
+ placements(new Uint8Array([10, 11, 12]), '[null]', '{}')
218
+ ),
219
+ serializableCase(
220
+ 'binary:column',
221
+ new Binary(Buffer.from([13, 14, 15]), Binary.SUBTYPE_COLUMN),
222
+ placements(new Uint8Array([13, 14, 15]), '[null]', '{}')
223
+ ),
224
+ serializableCase(
225
+ 'binary:sensitive',
226
+ new Binary(Buffer.from([16, 17, 18]), Binary.SUBTYPE_SENSITIVE),
227
+ placements(new Uint8Array([16, 17, 18]), '[null]', '{}')
228
+ ),
229
+ serializableCase(
230
+ 'binary:vector',
231
+ new Binary(Buffer.from([19, 20, 21]), Binary.SUBTYPE_VECTOR),
232
+ placements(new Uint8Array([19, 20, 21]), '[null]', '{}')
233
+ ),
234
+ serializableCase(
235
+ 'binary:userDefined',
236
+ new Binary(Buffer.from([22, 23, 24]), Binary.SUBTYPE_USER_DEFINED),
237
+ placements(new Uint8Array([22, 23, 24]), '[null]', '{}')
238
+ ),
239
+ // Degenerate arrays: The string keys are not spec-compliant, but ignored by the parsers.
240
+ {
241
+ name: 'array:invalid-key:alpha',
242
+ buildBuffer: (placement) =>
243
+ rawCaseDocument(
244
+ `array:invalid-key:alpha:${placement}`,
245
+ placement,
246
+ 0x04,
247
+ bsonDocument([bsonElement(0x10, 'alpha', int32(1))])
248
+ ),
249
+ expected: placements('[1]', '[[1]]', '{"nested":[1]}')
250
+ },
251
+ {
252
+ name: 'array:invalid-key:leading-zero',
253
+ buildBuffer: (placement) =>
254
+ rawCaseDocument(
255
+ `array:invalid-key:leading-zero:${placement}`,
256
+ placement,
257
+ 0x04,
258
+ bsonDocument([bsonElement(0x10, '01', int32(1))])
259
+ ),
260
+ expected: placements('[1]', '[[1]]', '{"nested":[1]}')
261
+ },
262
+ {
263
+ name: 'array:invalid-key:gap',
264
+ buildBuffer: (placement) =>
265
+ rawCaseDocument(
266
+ `array:invalid-key:gap:${placement}`,
267
+ placement,
268
+ 0x04,
269
+ bsonDocument([bsonElement(0x10, '1', int32(1))])
270
+ ),
271
+ expected: placements('[1]', '[[1]]', '{"nested":[1]}')
272
+ },
273
+ {
274
+ name: 'array:invalid-key:negative',
275
+ buildBuffer: (placement) =>
276
+ rawCaseDocument(
277
+ `array:invalid-key:negative:${placement}`,
278
+ placement,
279
+ 0x04,
280
+ bsonDocument([bsonElement(0x10, '-1', int32(1))])
281
+ ),
282
+ expected: placements('[1]', '[[1]]', '{"nested":[1]}')
283
+ },
284
+ {
285
+ name: 'array:invalid-key:mixed',
286
+ buildBuffer: (placement) =>
287
+ rawCaseDocument(
288
+ `array:invalid-key:mixed:${placement}`,
289
+ placement,
290
+ 0x04,
291
+ bsonDocument([bsonElement(0x10, '0', int32(1)), bsonElement(0x10, 'alpha', int32(2))])
292
+ ),
293
+ expected: placements('[1,2]', '[[1,2]]', '{"nested":[1,2]}')
294
+ },
295
+ {
296
+ name: 'array:invalid-key:reversed',
297
+ buildBuffer: (placement) =>
298
+ rawCaseDocument(
299
+ `array:invalid-key:reversed:${placement}`,
300
+ placement,
301
+ 0x04,
302
+ bsonDocument([bsonElement(0x10, '1', int32(1)), bsonElement(0x10, '0', int32(2))])
303
+ ),
304
+ expected: placements('[1,2]', '[[1,2]]', '{"nested":[1,2]}')
305
+ }
306
+ ];
307
+
308
+ const INVALID_UUID_LENGTHS = [0, 1, 15, 17] as const;
309
+ const DATE_COMPATIBILITY_CASES = [
310
+ {
311
+ name: 'legacy-edition',
312
+ context: CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY,
313
+ normal: '2023-03-06 13:47:00.000Z',
314
+ positiveExtended: '+010000-01-01 00:00:00.000Z',
315
+ negativeExtended: '-000001-12-31 23:59:59.999Z'
316
+ },
317
+ {
318
+ name: 'sync-streams-edition',
319
+ context: new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }),
320
+ normal: '2023-03-06T13:47:00.000Z',
321
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
322
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
323
+ },
324
+ {
325
+ name: 'compiled-streams-edition',
326
+ context: new CompatibilityContext({ edition: CompatibilityEdition.COMPILED_STREAMS }),
327
+ normal: '2023-03-06T13:47:00.000Z',
328
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
329
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
330
+ },
331
+ {
332
+ name: 'legacy-with-iso-override-enabled',
333
+ context: new CompatibilityContext({
334
+ edition: CompatibilityEdition.LEGACY,
335
+ overrides: new Map([[CompatibilityOption.timestampsIso8601, true]])
336
+ }),
337
+ normal: '2023-03-06T13:47:00.000Z',
338
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
339
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
340
+ },
341
+ {
342
+ name: 'sync-streams-with-iso-override-disabled',
343
+ context: new CompatibilityContext({
344
+ edition: CompatibilityEdition.SYNC_STREAMS,
345
+ overrides: new Map([[CompatibilityOption.timestampsIso8601, false]])
346
+ }),
347
+ normal: '2023-03-06 13:47:00.000Z',
348
+ positiveExtended: '+010000-01-01 00:00:00.000Z',
349
+ negativeExtended: '-000001-12-31 23:59:59.999Z'
350
+ },
351
+ {
352
+ name: 'sync-streams-seconds-precision',
353
+ context: new CompatibilityContext({
354
+ edition: CompatibilityEdition.SYNC_STREAMS,
355
+ maxTimeValuePrecision: TimeValuePrecision.seconds
356
+ }),
357
+ normal: '2023-03-06T13:47:00Z',
358
+ positiveExtended: '+010000-01-01T00:00:00Z',
359
+ negativeExtended: '-000001-12-31T23:59:59Z'
360
+ },
361
+ {
362
+ name: 'sync-streams-milliseconds-precision',
363
+ context: new CompatibilityContext({
364
+ edition: CompatibilityEdition.SYNC_STREAMS,
365
+ maxTimeValuePrecision: TimeValuePrecision.milliseconds
366
+ }),
367
+ normal: '2023-03-06T13:47:00.000Z',
368
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
369
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
370
+ },
371
+ {
372
+ name: 'sync-streams-microseconds-clamped',
373
+ context: new CompatibilityContext({
374
+ edition: CompatibilityEdition.SYNC_STREAMS,
375
+ maxTimeValuePrecision: TimeValuePrecision.microseconds
376
+ }),
377
+ normal: '2023-03-06T13:47:00.000Z',
378
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
379
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
380
+ },
381
+ {
382
+ name: 'sync-streams-nanoseconds-clamped',
383
+ context: new CompatibilityContext({
384
+ edition: CompatibilityEdition.SYNC_STREAMS,
385
+ maxTimeValuePrecision: TimeValuePrecision.nanoseconds
386
+ }),
387
+ normal: '2023-03-06T13:47:00.000Z',
388
+ positiveExtended: '+010000-01-01T00:00:00.000Z',
389
+ negativeExtended: '-000001-12-31T23:59:59.999Z'
390
+ },
391
+ {
392
+ name: 'legacy-with-iso-override-and-seconds',
393
+ context: new CompatibilityContext({
394
+ edition: CompatibilityEdition.LEGACY,
395
+ overrides: new Map([[CompatibilityOption.timestampsIso8601, true]]),
396
+ maxTimeValuePrecision: TimeValuePrecision.seconds
397
+ }),
398
+ normal: '2023-03-06T13:47:00Z',
399
+ positiveExtended: '+010000-01-01T00:00:00Z',
400
+ negativeExtended: '-000001-12-31T23:59:59Z'
401
+ },
402
+ {
403
+ name: 'sync-streams-iso-disabled-ignores-precision',
404
+ context: new CompatibilityContext({
405
+ edition: CompatibilityEdition.SYNC_STREAMS,
406
+ overrides: new Map([[CompatibilityOption.timestampsIso8601, false]]),
407
+ maxTimeValuePrecision: TimeValuePrecision.seconds
408
+ }),
409
+ normal: '2023-03-06 13:47:00.000Z',
410
+ positiveExtended: '+010000-01-01 00:00:00.000Z',
411
+ negativeExtended: '-000001-12-31 23:59:59.999Z'
412
+ }
413
+ ] as const;
414
+
415
+ for (const length of INVALID_UUID_LENGTHS) {
416
+ testCases.push(
417
+ serializableCase(
418
+ `binary:uuid:invalid-length:${length}`,
419
+ new Binary(Buffer.alloc(length, 0x11), Binary.SUBTYPE_UUID),
420
+ placements(new Uint8Array(Buffer.alloc(length, 0x11).buffer), '[null]', '{}')
421
+ )
422
+ );
423
+ }
424
+
425
+ for (const dateCase of DATE_COMPATIBILITY_CASES) {
426
+ testCases.push(
427
+ {
428
+ name: `date:compat:${dateCase.name}`,
429
+ context: dateCase.context,
430
+ buildBuffer: dateBufferForPlacement,
431
+ expectedId: (placement) => `compatibility-date:${placement}`,
432
+ expected: jsonStringPlacements(dateCase.normal)
433
+ },
434
+ {
435
+ name: `date:+010000:compat:${dateCase.name}`,
436
+ context: dateCase.context,
437
+ buildBuffer: (placement) =>
438
+ serializeCaseDocument(`compatibility-date:+010000:${placement}`, placement, positiveExtendedDate),
439
+ expectedId: (placement) => `compatibility-date:+010000:${placement}`,
440
+ expected: jsonStringPlacements(dateCase.positiveExtended)
441
+ },
442
+ {
443
+ name: `date:-000001:compat:${dateCase.name}`,
444
+ context: dateCase.context,
445
+ buildBuffer: (placement) =>
446
+ serializeCaseDocument(`compatibility-date:-000001:${placement}`, placement, negativeExtendedDate),
447
+ expectedId: (placement) => `compatibility-date:-000001:${placement}`,
448
+ expected: jsonStringPlacements(dateCase.negativeExtended)
449
+ }
450
+ );
451
+ }
452
+
453
+ describe('SourceRowConverter.rawToSqliteRow expected output', () => {
454
+ for (const parityCase of testCases) {
455
+ const context = parityCase.context ?? CONTEXT;
456
+ const legacyConverter = new LegacySourceRowConverter(context);
457
+ const directConverter = new DirectSourceRowConverter(context);
458
+
459
+ for (const placement of PLACEMENTS) {
460
+ test(`default output for ${parityCase.name} as ${placementLabel(placement)}`, () => {
461
+ expectNormalizedRow(legacyConverter, parityCase.buildBuffer(placement), {
462
+ _id: parityCase.expectedId?.(placement) ?? `${parityCase.name}:${placement}`,
463
+ value: parityCase.expected[placement]
464
+ });
465
+ });
466
+
467
+ test(`custom output for ${parityCase.name} as ${placementLabel(placement)}`, () => {
468
+ expectNormalizedRow(directConverter, parityCase.buildBuffer(placement), {
469
+ _id: parityCase.expectedId?.(placement) ?? `${parityCase.name}:${placement}`,
470
+ value: parityCase.expected[placement]
471
+ });
472
+ });
473
+ }
474
+ }
475
+
476
+ test('default output for 21 nested object levels', () => {
477
+ expectNormalizedRow(
478
+ legacyConverter,
479
+ BSON.serialize({
480
+ _id: 'depth-21',
481
+ value: deepNestedObject(21)
482
+ }) as Buffer,
483
+ {
484
+ _id: 'depth-21',
485
+ value: depth21Expected
486
+ }
487
+ );
488
+ });
489
+
490
+ test('custom output for 21 nested object levels', () => {
491
+ expectNormalizedRow(
492
+ directConverter,
493
+ BSON.serialize({
494
+ _id: 'depth-21',
495
+ value: deepNestedObject(21)
496
+ }) as Buffer,
497
+ {
498
+ _id: 'depth-21',
499
+ value: depth21Expected
500
+ }
501
+ );
502
+ });
503
+ });
504
+
505
+ describe('SourceRowConverter.rawToSqliteRow regex option preservation', () => {
506
+ // These cases intentionally diverge from the default implementation:
507
+ // The default implementation parsed to a JS-compatible RegExp, converting
508
+ // some options such as s -> g, and failing hard on some invalid cases such as "ii".
509
+ // The custom implementation preserves options as-is, even when invalid according to the BSON spec.
510
+ // Additionally, the default implementation performed some RegExp pattern normalization, which we don't do
511
+ // in the custom parser.
512
+ const regexDivergenceCases = [
513
+ {
514
+ name: 'regex',
515
+ buildBuffer: (placement: Placement) =>
516
+ serializeCaseDocument(`regex:${placement}`, placement, new BSONRegExp('a\\s+"b"', 'ims')),
517
+ testDescription: 'preserves BSON regex options on custom converter',
518
+ defaultExpected: jsonTextPlacements('{"pattern":"a\\\\s+\\"b\\"","options":"gim"}'),
519
+ customExpected: jsonTextPlacements('{"pattern":"a\\\\s+\\"b\\"","options":"ims"}')
520
+ },
521
+ {
522
+ name: 'regex:flags:s',
523
+ buildBuffer: (placement: Placement) =>
524
+ serializeCaseDocument(`regex:flags:s:${placement}`, placement, new BSONRegExp('a', 's')),
525
+ testDescription: 'preserves BSON regex options on custom converter',
526
+ defaultExpected: jsonTextPlacements('{"pattern":"a","options":"g"}'),
527
+ customExpected: jsonTextPlacements('{"pattern":"a","options":"s"}')
528
+ },
529
+ {
530
+ name: 'regex:flags:x',
531
+ buildBuffer: (placement: Placement) =>
532
+ serializeCaseDocument(`regex:flags:x:${placement}`, placement, new BSONRegExp('a', 'x')),
533
+ testDescription: 'preserves BSON regex options on custom converter',
534
+ defaultExpected: jsonTextPlacements('{"pattern":"a","options":""}'),
535
+ customExpected: jsonTextPlacements('{"pattern":"a","options":"x"}')
536
+ },
537
+ {
538
+ name: 'regex:flags:u',
539
+ buildBuffer: (placement: Placement) =>
540
+ serializeCaseDocument(`regex:flags:u:${placement}`, placement, new BSONRegExp('a', 'u')),
541
+ testDescription: 'preserves BSON regex options on custom converter',
542
+ defaultExpected: jsonTextPlacements('{"pattern":"a","options":""}'),
543
+ customExpected: jsonTextPlacements('{"pattern":"a","options":"u"}')
544
+ },
545
+ {
546
+ name: 'regex:flags:imsxu',
547
+ buildBuffer: (placement: Placement) =>
548
+ serializeCaseDocument(`regex:flags:imsxu:${placement}`, placement, new BSONRegExp('a', 'imsxu')),
549
+ testDescription: 'preserves BSON regex options on custom converter',
550
+ defaultExpected: jsonTextPlacements('{"pattern":"a","options":"gim"}'),
551
+ customExpected: jsonTextPlacements('{"pattern":"a","options":"imsux"}')
552
+ },
553
+ {
554
+ name: 'regex:raw:quote-and-backslash-options',
555
+ buildBuffer: (placement: Placement) =>
556
+ rawCaseDocument(
557
+ `regex:raw:quote-and-backslash-options:${placement}`,
558
+ placement,
559
+ 0x0b,
560
+ Buffer.concat([cstring('a"b\\c\n\t☃'), cstring('i"\\x')])
561
+ ),
562
+ testDescription: 'escapes special characters correctly',
563
+ defaultExpected: placements(
564
+ '{"pattern":"a\\"b\\\\c\\\\n\\t☃","options":"i"}',
565
+ '[{"pattern":"a\\"b\\\\c\\\\n\\t☃","options":"i"}]',
566
+ '{"nested":{"pattern":"a\\"b\\\\c\\\\n\\t☃","options":"i"}}'
567
+ ),
568
+ customExpected: placements(
569
+ '{"pattern":"a\\"b\\\\c\\n\\t☃","options":"i\\"\\\\x"}',
570
+ '[{"pattern":"a\\"b\\\\c\\n\\t☃","options":"i\\"\\\\x"}]',
571
+ '{"nested":{"pattern":"a\\"b\\\\c\\n\\t☃","options":"i\\"\\\\x"}}'
572
+ )
573
+ },
574
+ {
575
+ name: 'regex:raw:quoted-options-only',
576
+ buildBuffer: (placement: Placement) =>
577
+ rawCaseDocument(
578
+ `regex:raw:quoted-options-only:${placement}`,
579
+ placement,
580
+ 0x0b,
581
+ Buffer.concat([cstring('line1\nline2\t"q"'), cstring('"\\')])
582
+ ),
583
+ testDescription: 'escapes special characters correctly',
584
+ defaultExpected: placements(
585
+ '{"pattern":"line1\\\\nline2\\t\\"q\\"","options":""}',
586
+ '[{"pattern":"line1\\\\nline2\\t\\"q\\"","options":""}]',
587
+ '{"nested":{"pattern":"line1\\\\nline2\\t\\"q\\"","options":""}}'
588
+ ),
589
+ customExpected: placements(
590
+ '{"pattern":"line1\\nline2\\t\\"q\\"","options":"\\"\\\\"}',
591
+ '[{"pattern":"line1\\nline2\\t\\"q\\"","options":"\\"\\\\"}]',
592
+ '{"nested":{"pattern":"line1\\nline2\\t\\"q\\"","options":"\\"\\\\"}}'
593
+ )
594
+ },
595
+ {
596
+ name: 'regex:raw:unsorted-valid-options',
597
+ buildBuffer: (placement: Placement) =>
598
+ rawCaseDocument(
599
+ `regex:raw:unsorted-valid-options:${placement}`,
600
+ placement,
601
+ 0x0b,
602
+ Buffer.concat([cstring('a'), cstring('mi')])
603
+ ),
604
+ testDescription: 'documents default-vs-raw normalization differences',
605
+ defaultExpected: placements(
606
+ '{"pattern":"a","options":"im"}',
607
+ '[{"pattern":"a","options":"im"}]',
608
+ '{"nested":{"pattern":"a","options":"im"}}'
609
+ ),
610
+ customExpected: placements(
611
+ '{"pattern":"a","options":"mi"}',
612
+ '[{"pattern":"a","options":"mi"}]',
613
+ '{"nested":{"pattern":"a","options":"mi"}}'
614
+ )
615
+ },
616
+ {
617
+ name: 'regex:raw:unsorted-mixed-options',
618
+ buildBuffer: (placement: Placement) =>
619
+ rawCaseDocument(
620
+ `regex:raw:unsorted-mixed-options:${placement}`,
621
+ placement,
622
+ 0x0b,
623
+ Buffer.concat([cstring('a'), cstring('xim')])
624
+ ),
625
+ testDescription: 'documents default-vs-raw normalization differences',
626
+ defaultExpected: placements(
627
+ '{"pattern":"a","options":"im"}',
628
+ '[{"pattern":"a","options":"im"}]',
629
+ '{"nested":{"pattern":"a","options":"im"}}'
630
+ ),
631
+ customExpected: placements(
632
+ '{"pattern":"a","options":"xim"}',
633
+ '[{"pattern":"a","options":"xim"}]',
634
+ '{"nested":{"pattern":"a","options":"xim"}}'
635
+ )
636
+ },
637
+ {
638
+ name: 'regex:raw:empty-pattern',
639
+ buildBuffer: (placement: Placement) =>
640
+ rawCaseDocument(
641
+ `regex:raw:empty-pattern:${placement}`,
642
+ placement,
643
+ 0x0b,
644
+ Buffer.concat([cstring(''), cstring('x"\\')])
645
+ ),
646
+ testDescription: 'documents default-vs-raw normalization differences',
647
+ defaultExpected: placements(
648
+ '{"pattern":"(?:)","options":""}',
649
+ '[{"pattern":"(?:)","options":""}]',
650
+ '{"nested":{"pattern":"(?:)","options":""}}'
651
+ ),
652
+ customExpected: placements(
653
+ '{"pattern":"","options":"x\\"\\\\"}',
654
+ '[{"pattern":"","options":"x\\"\\\\"}]',
655
+ '{"nested":{"pattern":"","options":"x\\"\\\\"}}'
656
+ )
657
+ }
658
+ ] as const;
659
+
660
+ for (const regexCase of regexDivergenceCases) {
661
+ for (const placement of PLACEMENTS) {
662
+ test(`${regexCase.name} ${regexCase.testDescription} as ${placementLabel(placement)}`, () => {
663
+ const source = regexCase.buildBuffer(placement);
664
+
665
+ // Parity is intentionally not expected here. The default path converts BSON regexes
666
+ // through JS RegExp.flags or reconstructs JS RegExp semantics, while the raw
667
+ // path preserves the BSON pattern/options bytes as-is.
668
+ expectNormalizedRow(legacyConverter, source, {
669
+ _id: `${regexCase.name}:${placement}`,
670
+ value: regexCase.defaultExpected[placement]
671
+ });
672
+ expectNormalizedRow(directConverter, source, {
673
+ _id: `${regexCase.name}:${placement}`,
674
+ value: regexCase.customExpected[placement]
675
+ });
676
+ });
677
+ }
678
+ }
679
+
680
+ test('unsupported BSON regex flag is preserved only on the custom raw converter', () => {
681
+ const source = rawCaseDocument('regex:invalid:z', 'top', 0x0b, Buffer.concat([cstring('a'), cstring('z')]));
682
+
683
+ expectNormalizedRow(legacyConverter, source, {
684
+ _id: 'regex:invalid:z',
685
+ value: '{"pattern":"a","options":""}'
686
+ });
687
+ expectNormalizedRow(directConverter, source, {
688
+ _id: 'regex:invalid:z',
689
+ value: '{"pattern":"a","options":"z"}'
690
+ });
691
+ });
692
+
693
+ test('duplicate BSON regex flags are preserved only on the custom raw converter', () => {
694
+ const source = rawCaseDocument('regex:invalid:ii', 'top', 0x0b, Buffer.concat([cstring('a'), cstring('ii')]));
695
+
696
+ expectRowFailure(legacyConverter, source, "Invalid flags supplied to RegExp constructor 'ii'");
697
+ // The raw converter preserves the BSON option string even when it is not valid JS RegExp flags.
698
+ expectNormalizedRow(directConverter, source, {
699
+ _id: 'regex:invalid:ii',
700
+ value: '{"pattern":"a","options":"ii"}'
701
+ });
702
+ });
703
+ });
704
+
705
+ describe('SourceRowConverter.rawToSqliteRow invalid UTF-8', () => {
706
+ // The upstream bson parser validates UTF-8 strings by default.
707
+ // Our custom parser accepts invalid UTF-8 strings, using the replacement character in JSON.
708
+
709
+ test('invalid UTF-8 in top-level string is accepted only on the custom raw converter', () => {
710
+ const source = bsonDocument([
711
+ bsonElement(0x02, '_id', bsonString('invalid-utf8:top-string')),
712
+ bsonElement(0x02, 'value', Buffer.concat([int32(2), Buffer.from([0xff, 0x00])]))
713
+ ]);
714
+
715
+ expectRowFailure(legacyConverter, source, 'Invalid UTF-8 string in BSON document');
716
+ expectNormalizedRow(directConverter, source, {
717
+ _id: 'invalid-utf8:top-string',
718
+ value: '�'
719
+ });
720
+ });
721
+
722
+ test('invalid UTF-8 in nested string is accepted only on the custom raw converter', () => {
723
+ const source = bsonDocument([
724
+ bsonElement(0x02, '_id', bsonString('invalid-utf8:nested-string')),
725
+ bsonElement(
726
+ 0x03,
727
+ 'value',
728
+ bsonDocument([bsonElement(0x02, 'nested', Buffer.concat([int32(2), Buffer.from([0xff, 0x00])]))])
729
+ )
730
+ ]);
731
+
732
+ expectRowFailure(legacyConverter, source, 'Invalid UTF-8 string in BSON document');
733
+ expectNormalizedRow(directConverter, source, {
734
+ _id: 'invalid-utf8:nested-string',
735
+ value: '{"nested":"�"}'
736
+ });
737
+ });
738
+ });
739
+
740
+ describe('SourceRowConverter.rawToSqliteRow malformed BSON lengths', () => {
741
+ test('overlong top-level string length fails instead of hanging', () => {
742
+ const source = bsonDocument([
743
+ bsonElement(0x02, '_id', bsonString('malformed-length:top-string')),
744
+ // Declares far more string bytes than exist in the document.
745
+ bsonElement(0x02, 'value', Buffer.concat([int32(1000), Buffer.from([0xff, 0x00])]))
746
+ ]);
747
+
748
+ expect(captureRow(legacyConverter, source).ok).toBe(false);
749
+ expectRowFailure(directConverter, source, 'Invalid BSON string length');
750
+ });
751
+ });
752
+
753
+ describe('SourceRowConverter.rawToSqliteRow fuzz', () => {
754
+ test('matches across randomized supported documents', () => {
755
+ const rng = makeRng(0x5eedc0de);
756
+
757
+ for (let i = 0; i < 150; i++) {
758
+ const source = BSON.serialize(
759
+ {
760
+ _id: `fuzz:${i}:${randomString(rng, 0, 6)}`,
761
+ [`root:${randomString(rng, 0, 5)}`]: randomSupportedValue(rng),
762
+ value: randomSupportedValue(rng)
763
+ },
764
+ { ignoreUndefined: false }
765
+ ) as Buffer;
766
+
767
+ expectRowParity(source);
768
+ }
769
+ });
770
+
771
+ test('matches on a large nested string that grows the JSON writer buffer', () => {
772
+ const source = BSON.serialize({
773
+ _id: 'large-string',
774
+ value: {
775
+ nested: 'x'.repeat(1024 * 1024 + 4096)
776
+ }
777
+ }) as Buffer;
778
+
779
+ expectRowParity(source);
780
+ });
781
+
782
+ test('matches on escape-heavy keys and values', () => {
783
+ const source = BSON.serialize({
784
+ _id: 'escapes',
785
+ 'quote"slash\\newline\n': {
786
+ '\tcontrol\u0001': ['line 1\nline 2', '"quoted"', '☃']
787
+ },
788
+ value: {
789
+ 中: ['\\', '"', '\r', '\t', '\u0001']
790
+ }
791
+ }) as Buffer;
792
+
793
+ expectRowParity(source);
794
+ });
795
+
796
+ test('matches on large escape-heavy nested values serialized from BSON', () => {
797
+ // For this test to work, this must be larger than the default buffer size
798
+ const escapeHeavy = '\u0001'.repeat(1024 * 400);
799
+ const source = BSON.serialize({
800
+ _id: 'escapes-large',
801
+ value: {
802
+ nested: escapeHeavy
803
+ }
804
+ }) as Buffer;
805
+
806
+ expectRowParity(source);
807
+ });
808
+
809
+ test('matches 21 nested object levels', () => {
810
+ expectRowParity(
811
+ BSON.serialize({
812
+ _id: 'depth-21',
813
+ value: deepNestedObject(21)
814
+ }) as Buffer
815
+ );
816
+ });
817
+ });
818
+
819
+ describe('SourceRowConverter.rawToSqliteRow full output parity', () => {
820
+ test('matches replicaId when row parity succeeds', () => {
821
+ const source = BSON.serialize({
822
+ _id: 'replica-id',
823
+ value: new Int32(7)
824
+ }) as Buffer;
825
+
826
+ expect(captureOutput(directConverter, source)).toEqual(captureOutput(legacyConverter, source));
827
+ });
828
+
829
+ test('default full output matches expected replicaId and row', () => {
830
+ const source = BSON.serialize({
831
+ _id: 'replica-id',
832
+ value: new Int32(7)
833
+ }) as Buffer;
834
+
835
+ expect(captureOutput(legacyConverter, source)).toEqual({
836
+ ok: true,
837
+ output: normalize({
838
+ replicaId: 'replica-id',
839
+ row: {
840
+ _id: 'replica-id',
841
+ value: 7n
842
+ }
843
+ })
844
+ });
845
+ });
846
+
847
+ test('custom full output matches expected replicaId and row', () => {
848
+ const source = BSON.serialize({
849
+ _id: 'replica-id',
850
+ value: new Int32(7)
851
+ }) as Buffer;
852
+
853
+ expect(captureOutput(directConverter, source)).toEqual({
854
+ ok: true,
855
+ output: normalize({
856
+ replicaId: 'replica-id',
857
+ row: {
858
+ _id: 'replica-id',
859
+ value: 7n
860
+ }
861
+ })
862
+ });
863
+ });
864
+ });
865
+
866
+ describe('SourceRowConverter.rawToSqliteRow DBRef handling', () => {
867
+ test('documents why DBRef differs from deprecated DBPointer handling', () => {
868
+ // These are _not_ serialized as a DBPointer value. It is a normal document,
869
+ // with $ref, $id, $db keys.
870
+ const source = BSON.serialize({
871
+ _id: 'dbref',
872
+ value: new DBRef('mycollection', new ObjectId('66e834cc91d805df11fa0ecb'), 'mydb', { foo: 'bar' })
873
+ }) as Buffer;
874
+
875
+ // LegacySourceRowConverter serializes the DBRef instance fields.
876
+ expectNormalizedRow(legacyConverter, source, {
877
+ _id: 'dbref',
878
+ value: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","db":"mydb","fields":{"foo":"bar"}}'
879
+ });
880
+
881
+ // DirectSourceRowConverter serializes the raw BSON document shape for DBRef.
882
+ // We intentionally do not attempt to convert this type.
883
+ expectNormalizedRow(directConverter, source, {
884
+ _id: 'dbref',
885
+ value: '{"$ref":"mycollection","$id":"66e834cc91d805df11fa0ecb","$db":"mydb","foo":"bar"}'
886
+ });
887
+ });
888
+ });
889
+
890
+ function serializeCaseDocument(id: string, placement: Placement, value: unknown): Buffer {
891
+ if (placement === 'top') {
892
+ return BSON.serialize({ _id: id, value }, { ignoreUndefined: false }) as Buffer;
893
+ }
894
+ if (placement === 'array') {
895
+ return BSON.serialize({ _id: id, value: [value] }, { ignoreUndefined: false }) as Buffer;
896
+ }
897
+ return BSON.serialize({ _id: id, value: { nested: value } }, { ignoreUndefined: false }) as Buffer;
898
+ }
899
+
900
+ function int32(value: number): Buffer {
901
+ const bytes = Buffer.alloc(4);
902
+ bytes.writeInt32LE(value);
903
+ return bytes;
904
+ }
905
+
906
+ function cstring(value: string): Buffer {
907
+ return Buffer.concat([Buffer.from(value, 'utf8'), Buffer.from([0])]);
908
+ }
909
+
910
+ function bsonString(value: string): Buffer {
911
+ const bytes = Buffer.from(value, 'utf8');
912
+ return Buffer.concat([int32(bytes.length + 1), bytes, Buffer.from([0])]);
913
+ }
914
+
915
+ function bsonDocument(elements: Buffer[]): Buffer {
916
+ const body = Buffer.concat([...elements, Buffer.from([0])]);
917
+ return Buffer.concat([int32(body.length + 4), body]);
918
+ }
919
+
920
+ function bsonElement(type: number, key: string, payload: Buffer = Buffer.alloc(0)): Buffer {
921
+ return Buffer.concat([Buffer.from([type]), cstring(key), payload]);
922
+ }
923
+
924
+ function rawCaseDocument(id: string, placement: Placement, type: number, payload: Buffer): Buffer {
925
+ const valueElement =
926
+ placement === 'top'
927
+ ? bsonElement(type, 'value', payload)
928
+ : placement === 'array'
929
+ ? bsonElement(0x04, 'value', bsonDocument([bsonElement(type, '0', payload)]))
930
+ : bsonElement(0x03, 'value', bsonDocument([bsonElement(type, 'nested', payload)]));
931
+
932
+ return bsonDocument([bsonElement(0x02, '_id', bsonString(id)), valueElement]);
933
+ }
934
+
935
+ function placements(top: unknown, array: unknown, nested: unknown): ExpectedPlacements {
936
+ return { top, array, nested };
937
+ }
938
+
939
+ function jsonStringPlacements(top: string): ExpectedPlacements {
940
+ return placements(top, `[${JSON.stringify(top)}]`, `{"nested":${JSON.stringify(top)}}`);
941
+ }
942
+
943
+ function jsonTextPlacements(top: string): ExpectedPlacements {
944
+ return placements(top, `[${top}]`, `{"nested":${top}}`);
945
+ }
946
+
947
+ function serializableCase(name: string, value: unknown, expected: ExpectedPlacements): ConverterCase {
948
+ return {
949
+ name,
950
+ buildBuffer: (placement) => serializeCaseDocument(`${name}:${placement}`, placement, value),
951
+ expected
952
+ };
953
+ }
954
+
955
+ function rawCase(name: string, type: number, payload: Buffer, expected: ExpectedPlacements): ConverterCase {
956
+ return {
957
+ name,
958
+ buildBuffer: (placement) => rawCaseDocument(`${name}:${placement}`, placement, type, payload),
959
+ expected
960
+ };
961
+ }
962
+
963
+ function dateBufferForPlacement(placement: Placement): Buffer {
964
+ return serializeCaseDocument(`compatibility-date:${placement}`, placement, normalDate);
965
+ }
966
+
967
+ /**
968
+ * Normalize Uint8Array -> hex string to make test output nicer.
969
+ */
970
+ function normalize(value: unknown): unknown {
971
+ if (Buffer.isBuffer(value)) {
972
+ throw new Error(`Unexpected buffer: Use standard Uint8Array instead.`);
973
+ }
974
+ if (value instanceof Uint8Array) {
975
+ return { __bytes: Buffer.from(value).toString('hex') };
976
+ }
977
+ if (Array.isArray(value)) {
978
+ return value.map((entry) => normalize(entry));
979
+ }
980
+ if (value != null && typeof value === 'object') {
981
+ const record: Record<string, unknown> = {};
982
+ for (const key of Object.keys(value).sort()) {
983
+ record[key] = normalize((value as Record<string, unknown>)[key]);
984
+ }
985
+ return record;
986
+ }
987
+ return value;
988
+ }
989
+
990
+ function errorMessage(error: unknown): string {
991
+ return error instanceof Error ? error.message : String(error);
992
+ }
993
+
994
+ function captureRow(converter: SourceRowConverter, source: Buffer): RowCapture {
995
+ try {
996
+ return {
997
+ ok: true,
998
+ row: normalize(converter.rawToSqliteRow(source).row)
999
+ };
1000
+ } catch (error) {
1001
+ return { ok: false, message: errorMessage(error) };
1002
+ }
1003
+ }
1004
+
1005
+ function expectRowFailure(converter: SourceRowConverter, source: Buffer, message: string) {
1006
+ expect(captureRow(converter, source)).toEqual({
1007
+ ok: false,
1008
+ message
1009
+ });
1010
+ }
1011
+
1012
+ function captureOutput(converter: SourceRowConverter, source: Buffer): OutputCapture {
1013
+ try {
1014
+ return {
1015
+ ok: true,
1016
+ output: normalize(converter.rawToSqliteRow(source))
1017
+ };
1018
+ } catch (error) {
1019
+ return { ok: false, message: errorMessage(error) };
1020
+ }
1021
+ }
1022
+
1023
+ function expectRowParity(source: Buffer) {
1024
+ expect(captureRow(directConverter, source)).toEqual(captureRow(legacyConverter, source));
1025
+ }
1026
+
1027
+ function expectNormalizedRow(converter: SourceRowConverter, source: Buffer, expected: Record<string, unknown>) {
1028
+ expect(captureRow(converter, source)).toEqual({
1029
+ ok: true,
1030
+ row: normalize(expected)
1031
+ });
1032
+ }
1033
+
1034
+ function placementLabel(placement: Placement): string {
1035
+ switch (placement) {
1036
+ case 'top':
1037
+ return 'top-level field';
1038
+ case 'array':
1039
+ return 'embedded in array';
1040
+ case 'nested':
1041
+ return 'embedded in nested document';
1042
+ }
1043
+ }
1044
+
1045
+ function makeRng(seed: number) {
1046
+ let state = seed >>> 0;
1047
+ return () => {
1048
+ state = (state + 0x6d2b79f5) >>> 0;
1049
+ let next = Math.imul(state ^ (state >>> 15), 1 | state);
1050
+ next ^= next + Math.imul(next ^ (next >>> 7), 61 | next);
1051
+ return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
1052
+ };
1053
+ }
1054
+
1055
+ function randomInt(rng: () => number, min: number, max: number) {
1056
+ return Math.floor(rng() * (max - min + 1)) + min;
1057
+ }
1058
+
1059
+ function pick<T>(rng: () => number, values: T[]): T {
1060
+ return values[randomInt(rng, 0, values.length - 1)];
1061
+ }
1062
+
1063
+ function randomString(rng: () => number, minLength: number, maxLength: number) {
1064
+ const alphabet = ['a', 'Z', '0', '9', ' ', '"', '\\', '\n', '\r', '\t', '/', '\u0001', 'é', '☃', '中'];
1065
+ const length = randomInt(rng, minLength, maxLength);
1066
+ let output = '';
1067
+ for (let i = 0; i < length; i++) {
1068
+ output += pick(rng, alphabet);
1069
+ }
1070
+ return output;
1071
+ }
1072
+
1073
+ function randomObjectId(rng: () => number) {
1074
+ const bytes = Buffer.alloc(12);
1075
+ for (let i = 0; i < bytes.length; i++) {
1076
+ bytes[i] = randomInt(rng, 0, 255);
1077
+ }
1078
+ return new ObjectId(bytes);
1079
+ }
1080
+
1081
+ function randomSafeDate(rng: () => number) {
1082
+ const min = Date.UTC(2000, 0, 1);
1083
+ const max = Date.UTC(2035, 11, 31, 23, 59, 59, 999);
1084
+ return new Date(Math.floor(rng() * (max - min + 1)) + min);
1085
+ }
1086
+
1087
+ function randomLeaf(rng: () => number) {
1088
+ switch (randomInt(rng, 0, 10)) {
1089
+ case 0:
1090
+ return new Double(Number((rng() * 1000 - 500).toFixed(6)));
1091
+ case 1:
1092
+ return randomString(rng, 0, 24);
1093
+ case 2:
1094
+ return randomInt(rng, 0, 1) === 0;
1095
+ case 3:
1096
+ return null;
1097
+ case 4:
1098
+ return randomObjectId(rng);
1099
+ case 5:
1100
+ return randomSafeDate(rng);
1101
+ case 6:
1102
+ return new Int32(randomInt(rng, -5000, 5000));
1103
+ case 7:
1104
+ return Long.fromBigInt(BigInt(randomInt(rng, -5000, 5000)) * 1000000000000n + 17n);
1105
+ case 8:
1106
+ return Decimal128.fromString(
1107
+ `${randomInt(rng, -999, 999)}.${randomInt(rng, 0, 9999).toString().padStart(4, '0')}`
1108
+ );
1109
+ case 9:
1110
+ return rng() < 0.5 ? new MinKey() : new MaxKey();
1111
+ default:
1112
+ return new Double(Number((rng() * 1000 - 500).toFixed(6)));
1113
+ }
1114
+ }
1115
+
1116
+ function randomSupportedValue(rng: () => number, depth = 0): unknown {
1117
+ if (depth >= 3) {
1118
+ return randomLeaf(rng);
1119
+ }
1120
+
1121
+ switch (randomInt(rng, 0, 4)) {
1122
+ case 0:
1123
+ case 1:
1124
+ return randomLeaf(rng);
1125
+ case 2: {
1126
+ const length = randomInt(rng, 0, 4);
1127
+ return Array.from({ length }, () => randomSupportedValue(rng, depth + 1));
1128
+ }
1129
+ default: {
1130
+ const entries = randomInt(rng, 0, 4);
1131
+ const record: Record<string, unknown> = {};
1132
+ for (let i = 0; i < entries; i++) {
1133
+ record[`key:${i}:${randomString(rng, 0, 8)}`] = randomSupportedValue(rng, depth + 1);
1134
+ }
1135
+ return record;
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ function deepNestedObject(depth: number): unknown {
1141
+ let value: unknown = 1;
1142
+ for (let i = 0; i < depth; i++) {
1143
+ value = { nested: value };
1144
+ }
1145
+ return value;
1146
+ }