@tldraw/sync-core 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248

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 (49) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  5. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  6. package/dist-cjs/lib/TLSyncClient.js +2 -3
  7. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncRoom.js +3 -2
  9. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  10. package/dist-esm/index.mjs +1 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  12. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  13. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  14. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  15. package/dist-esm/lib/TLSyncRoom.mjs +3 -2
  16. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  17. package/package.json +9 -8
  18. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  19. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  20. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  21. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  22. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  23. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  24. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  25. package/src/lib/TLSocketRoom.ts +6 -1
  26. package/src/lib/TLSyncClient.test.ts +1127 -846
  27. package/src/lib/TLSyncClient.ts +4 -4
  28. package/src/lib/TLSyncRoom.ts +5 -2
  29. package/src/lib/TLSyncStorage.test.ts +225 -0
  30. package/src/lib/chunk.test.ts +372 -0
  31. package/src/lib/diff.test.ts +885 -0
  32. package/src/lib/interval.test.ts +43 -0
  33. package/src/lib/protocol.test.ts +43 -0
  34. package/src/lib/recordDiff.test.ts +159 -0
  35. package/src/test/TLSocketRoom.test.ts +1109 -894
  36. package/src/test/TLSyncRoom.test.ts +1735 -893
  37. package/src/test/storageContractSuite.ts +618 -0
  38. package/src/test/upgradeDowngrade.test.ts +137 -37
  39. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  40. package/src/lib/RoomSession.test.ts +0 -101
  41. package/src/lib/computeTombstonePruning.test.ts +0 -352
  42. package/src/lib/server-types.test.ts +0 -44
  43. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  44. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  45. package/src/test/chunk.test.ts +0 -385
  46. package/src/test/customMessages.test.ts +0 -36
  47. package/src/test/diff.test.ts +0 -784
  48. package/src/test/presenceMode.test.ts +0 -149
  49. package/src/test/validation.test.ts +0 -186
@@ -12,7 +12,6 @@ import {
12
12
  exhaustiveSwitchError,
13
13
  isEqual,
14
14
  objectMapEntries,
15
- structuredClone,
16
15
  uniqueId,
17
16
  } from '@tldraw/utils'
18
17
  import {
@@ -853,10 +852,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
853
852
  // in offline mode, we only accumulate in speculativeChanges
854
853
  if (!this.isConnectedToRoom) return
855
854
  if (!this.unsentChanges.nextDiff) {
856
- this.unsentChanges.nextDiff = structuredClone(change)
857
- } else {
858
- squashRecordDiffsMutable(this.unsentChanges.nextDiff, [change])
855
+ this.unsentChanges.nextDiff = { added: {} as any, updated: {} as any, removed: {} as any }
859
856
  }
857
+ // records are immutable, so sharing their references with `change` is fine — the
858
+ // squash gives nextDiff its own containers and tuples without deep-cloning records
859
+ squashRecordDiffsMutable(this.unsentChanges.nextDiff, [change])
860
860
  this.sendUnsentChanges()
861
861
  }
862
862
 
@@ -379,8 +379,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
379
379
  session.debounceTimer = null
380
380
 
381
381
  if (session.outstandingDataMessages.length > 0) {
382
- session.socket.sendMessage({ type: 'data', data: session.outstandingDataMessages })
383
- session.outstandingDataMessages.length = 0
382
+ // hand the buffer over and start a fresh one, rather than truncating in
383
+ // place, so sockets that defer serialization don't see an emptied array
384
+ const data = session.outstandingDataMessages
385
+ session.outstandingDataMessages = []
386
+ session.socket.sendMessage({ type: 'data', data })
384
387
  }
385
388
  }
386
389
 
@@ -0,0 +1,225 @@
1
+ import {
2
+ BaseRecord,
3
+ createMigrationSequence,
4
+ createRecordType,
5
+ RecordId,
6
+ StoreSchema,
7
+ } from '@tldraw/store'
8
+ import { TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
9
+ import { describe, expect, it, vi } from 'vitest'
10
+ import {
11
+ contractRecords,
12
+ contractSchema,
13
+ makeContractSnapshot,
14
+ makePage,
15
+ } from '../test/storageContractSuite'
16
+ import { InMemorySyncStorage } from './InMemorySyncStorage'
17
+ import {
18
+ convertStoreSnapshotToRoomSnapshot,
19
+ loadSnapshotIntoStorage,
20
+ toNetworkDiff,
21
+ TLSyncForwardDiff,
22
+ } from './TLSyncStorage'
23
+
24
+ describe('toNetworkDiff', () => {
25
+ const pageA = makePage('a', 'A')
26
+ const pageB = { ...makePage('a', 'A'), name: 'B' } as TLRecord
27
+
28
+ it('[ND2] maps plain puts, update tuples, and deletes to network ops', () => {
29
+ const diff: TLSyncForwardDiff<TLRecord> = {
30
+ puts: {
31
+ [contractRecords[0].id]: contractRecords[0],
32
+ [pageA.id]: [pageA, pageB],
33
+ },
34
+ deletes: ['shape:gone'],
35
+ }
36
+ expect(toNetworkDiff(diff)).toEqual({
37
+ [contractRecords[0].id]: ['put', contractRecords[0]],
38
+ [pageA.id]: ['patch', { name: ['put', 'B'] }],
39
+ 'shape:gone': ['remove'],
40
+ })
41
+ })
42
+
43
+ it('[ND2] omits update tuples that compute to no diff', () => {
44
+ const diff: TLSyncForwardDiff<TLRecord> = {
45
+ puts: { [pageA.id]: [pageA, { ...pageA } as TLRecord] },
46
+ deletes: [],
47
+ }
48
+ expect(toNetworkDiff(diff)).toEqual({})
49
+ })
50
+
51
+ it('[ND2] returns an (possibly empty) object, never null', () => {
52
+ expect(toNetworkDiff({ puts: {}, deletes: [] })).toEqual({})
53
+ })
54
+ })
55
+
56
+ describe('convertStoreSnapshotToRoomSnapshot', () => {
57
+ it('[SL1] passes a RoomSnapshot through by reference', () => {
58
+ const roomSnapshot = makeContractSnapshot(contractRecords, { documentClock: 42 })
59
+ expect(convertStoreSnapshotToRoomSnapshot(roomSnapshot)).toBe(roomSnapshot)
60
+ })
61
+
62
+ it('[SL1] converts a StoreSnapshot to a clock-zero room snapshot', () => {
63
+ const storeSnapshot = {
64
+ store: {
65
+ [TLDOCUMENT_ID]: contractRecords[0],
66
+ [contractRecords[1].id]: contractRecords[1],
67
+ } as any,
68
+ schema: contractSchema.serialize(),
69
+ }
70
+ expect(convertStoreSnapshotToRoomSnapshot(storeSnapshot)).toEqual({
71
+ clock: 0,
72
+ documentClock: 0,
73
+ documents: [
74
+ { state: contractRecords[0], lastChangedClock: 0 },
75
+ { state: contractRecords[1], lastChangedClock: 0 },
76
+ ],
77
+ schema: storeSnapshot.schema,
78
+ tombstones: {},
79
+ })
80
+ })
81
+ })
82
+
83
+ describe('loadSnapshotIntoStorage', () => {
84
+ it('[SL2] throws when the snapshot has no schema', () => {
85
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
86
+ const storage = new InMemorySyncStorage<TLRecord>({
87
+ snapshot: makeContractSnapshot(contractRecords),
88
+ })
89
+ expect(() =>
90
+ storage.transaction((txn) => {
91
+ loadSnapshotIntoStorage(txn, contractSchema, { documents: [], clock: 0 } as any)
92
+ })
93
+ ).toThrow('Schema is required')
94
+ consoleSpy.mockRestore()
95
+ })
96
+
97
+ it('[SL3] writes new and changed documents and tombstones the rest', () => {
98
+ const extraPage = makePage('extra', 'Extra')
99
+ const storage = new InMemorySyncStorage<TLRecord>({
100
+ snapshot: makeContractSnapshot([...contractRecords, extraPage]),
101
+ })
102
+
103
+ const changedPage = { ...contractRecords[1], name: 'Renamed' } as TLRecord
104
+ const newSnapshot = makeContractSnapshot([contractRecords[0], changedPage])
105
+ storage.transaction((txn) => {
106
+ loadSnapshotIntoStorage(txn, contractSchema, newSnapshot)
107
+ })
108
+
109
+ expect(storage.documents.get(contractRecords[1].id)?.state).toEqual(changedPage)
110
+ expect(storage.documents.has(extraPage.id)).toBe(false)
111
+ expect(storage.tombstones.has(extraPage.id)).toBe(true)
112
+ })
113
+
114
+ it('[SL3] skips documents that are deep-equal to the stored state', () => {
115
+ const storage = new InMemorySyncStorage<TLRecord>({
116
+ snapshot: makeContractSnapshot(contractRecords, { documentClock: 5 }),
117
+ })
118
+ storage.transaction((txn) => {
119
+ loadSnapshotIntoStorage(txn, contractSchema, makeContractSnapshot(contractRecords))
120
+ })
121
+ // nothing changed, so the clock did not advance
122
+ expect(storage.getClock()).toBe(5)
123
+ })
124
+
125
+ it('[SL4] persists the snapshot schema and migrates loaded records to the current version', () => {
126
+ interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
127
+ value: number
128
+ migrated?: boolean
129
+ }
130
+ const TestRecordType = createRecordType<TestRecord>('test', {
131
+ validator: { validate: (r) => r as TestRecord },
132
+ scope: 'document',
133
+ })
134
+ const migrations = createMigrationSequence({
135
+ sequenceId: 'com.test.record',
136
+ retroactive: true,
137
+ sequence: [
138
+ {
139
+ id: 'com.test.record/1',
140
+ scope: 'record',
141
+ filter: (r: any) => r.typeName === 'test',
142
+ up: (record: any) => {
143
+ record.migrated = true
144
+ },
145
+ },
146
+ ],
147
+ })
148
+ const oldSchema = StoreSchema.create({ test: TestRecordType })
149
+ const newSchema = StoreSchema.create({ test: TestRecordType }, { migrations: [migrations] })
150
+
151
+ const storage = new InMemorySyncStorage<TestRecord>({
152
+ snapshot: {
153
+ documents: [],
154
+ clock: 0,
155
+ documentClock: 0,
156
+ schema: newSchema.serialize(),
157
+ },
158
+ })
159
+
160
+ const record: TestRecord = { id: 'test:1' as RecordId<TestRecord>, typeName: 'test', value: 1 }
161
+ storage.transaction((txn) => {
162
+ loadSnapshotIntoStorage(txn, newSchema, {
163
+ documents: [{ state: record, lastChangedClock: 0 }],
164
+ clock: 0,
165
+ documentClock: 0,
166
+ schema: oldSchema.serialize(),
167
+ })
168
+ })
169
+
170
+ expect((storage.documents.get('test:1')!.state as TestRecord).migrated).toBe(true)
171
+ // the schema was migrated up to the current version too
172
+ expect(storage.schema.get()).toEqual(newSchema.serialize())
173
+ })
174
+
175
+ it('[SL4] applies record migrations exactly once per record', () => {
176
+ const migrationCounts = new Map<string, number>()
177
+
178
+ interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
179
+ value: number
180
+ }
181
+ const TestRecordType = createRecordType<TestRecord>('test', {
182
+ validator: { validate: (r) => r as TestRecord },
183
+ scope: 'document',
184
+ })
185
+ const migrations = createMigrationSequence({
186
+ sequenceId: 'com.test.record',
187
+ retroactive: true,
188
+ sequence: [
189
+ {
190
+ id: 'com.test.record/1',
191
+ scope: 'record',
192
+ filter: (r: any) => r.typeName === 'test',
193
+ up: (record: any) => {
194
+ migrationCounts.set(record.id, (migrationCounts.get(record.id) ?? 0) + 1)
195
+ },
196
+ },
197
+ ],
198
+ })
199
+ const oldSchema = StoreSchema.create({ test: TestRecordType })
200
+ const newSchema = StoreSchema.create({ test: TestRecordType }, { migrations: [migrations] })
201
+
202
+ const numRecords = 100
203
+ const testRecords: TestRecord[] = Array.from({ length: numRecords }, (_, i) => ({
204
+ id: `test:${i}` as RecordId<TestRecord>,
205
+ typeName: 'test' as const,
206
+ value: i,
207
+ }))
208
+
209
+ const storage = new InMemorySyncStorage<TestRecord>({
210
+ snapshot: {
211
+ documents: testRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
212
+ clock: 0,
213
+ documentClock: 0,
214
+ schema: oldSchema.serialize(),
215
+ },
216
+ })
217
+
218
+ storage.transaction((txn) => {
219
+ newSchema.migrateStorage(txn)
220
+ })
221
+
222
+ expect(migrationCounts.size).toBe(numRecords)
223
+ expect([...migrationCounts.values()].every((count) => count === 1)).toBe(true)
224
+ })
225
+ })
@@ -0,0 +1,372 @@
1
+ import { assert } from 'tldraw'
2
+ import { JsonChunkAssembler, chunk } from './chunk'
3
+
4
+ describe('chunk (CH1–CH3)', () => {
5
+ describe('size boundary (CH1)', () => {
6
+ it('[CH1] returns the message as a single chunk when its length is strictly less than maxSize', () => {
7
+ expect(chunk('hello', 100)).toEqual(['hello'])
8
+ expect(chunk('x'.repeat(9), 10)).toEqual(['x'.repeat(9)])
9
+ })
10
+
11
+ it('[CH1] chunks a message whose length equals maxSize', () => {
12
+ const msg = 'x'.repeat(10)
13
+ const result = chunk(msg, 10)
14
+ expect(result.length).toBeGreaterThan(1)
15
+ expect(result[0]).toMatch(/^\d+_x+$/)
16
+ })
17
+
18
+ it('[CH1] handles empty strings', () => {
19
+ expect(chunk('', 100)).toEqual([''])
20
+ })
21
+
22
+ it('[CH1] handles single character strings', () => {
23
+ expect(chunk('a', 100)).toEqual(['a'])
24
+ })
25
+
26
+ it('[CH1] uses the default max size when none is provided', () => {
27
+ const longString = 'x'.repeat(262145) // larger than the 262144 default
28
+ const result = chunk(longString)
29
+ expect(result.length).toBeGreaterThan(1)
30
+ expect(result[0]).toMatch(/^\d+_x+$/)
31
+ })
32
+ })
33
+
34
+ describe('chunk format and ordering (CH2)', () => {
35
+ it('[CH2] prefixes chunks with a countdown and puts the start of the message first', () => {
36
+ expect(chunk('hello there my good world', 5)).toMatchInlineSnapshot(`
37
+ [
38
+ "8_h",
39
+ "7_ell",
40
+ "6_o t",
41
+ "5_her",
42
+ "4_e m",
43
+ "3_y g",
44
+ "2_ood",
45
+ "1_ wo",
46
+ "0_rld",
47
+ ]
48
+ `)
49
+
50
+ expect(chunk('hello there my good world', 10)).toMatchInlineSnapshot(`
51
+ [
52
+ "3_h",
53
+ "2_ello the",
54
+ "1_re my go",
55
+ "0_od world",
56
+ ]
57
+ `)
58
+ })
59
+
60
+ it('[CH2] concatenating the chunk bodies in order reconstructs the message', () => {
61
+ const original = 'hello world this is a long message'
62
+ const reconstructed = chunk(original, 8)
63
+ .map((c) => /^(\d+)_(.*)$/s.exec(c)![2])
64
+ .join('')
65
+ expect(reconstructed).toEqual(original)
66
+ })
67
+
68
+ it('[CH2] preserves unicode characters across chunk boundaries', () => {
69
+ const unicodeString = '🎨'.repeat(10) + '📐'.repeat(10) + '🗼️'.repeat(10)
70
+ const result = chunk(unicodeString, 20)
71
+ expect(result.length).toBeGreaterThan(1)
72
+
73
+ const reconstructed = result.map((c) => /^(\d+)_(.*)$/s.exec(c)![2]).join('')
74
+ expect(reconstructed).toEqual(unicodeString)
75
+ })
76
+
77
+ it('[CH2] reconstructs very large strings', () => {
78
+ const veryLargeString = 'a'.repeat(1000000) // 1MB string
79
+ const result = chunk(veryLargeString, 10000)
80
+ expect(result.length).toBeGreaterThan(1)
81
+
82
+ const reconstructed = result.map((c) => /^(\d+)_(.*)$/s.exec(c)![2]).join('')
83
+ expect(reconstructed).toEqual(veryLargeString)
84
+ })
85
+ })
86
+
87
+ describe('chunk size limits (CH3)', () => {
88
+ it('[CH3] keeps each chunk at or below maxSize', () => {
89
+ const maxSize = 15
90
+ for (const c of chunk('hello world this is a long message', maxSize)) {
91
+ expect(c.length).toBeLessThanOrEqual(maxSize)
92
+ }
93
+
94
+ expect(chunk('dark and stormy tonight', 4)).toMatchInlineSnapshot(`
95
+ [
96
+ "12_d",
97
+ "11_a",
98
+ "10_r",
99
+ "9_k ",
100
+ "8_an",
101
+ "7_d ",
102
+ "6_st",
103
+ "5_or",
104
+ "4_my",
105
+ "3_ t",
106
+ "2_on",
107
+ "1_ig",
108
+ "0_ht",
109
+ ]
110
+ `)
111
+ })
112
+
113
+ it('[CH3] carries at least one content character per chunk even when the prefix alone exceeds maxSize', () => {
114
+ const chunks = chunk('once upon a time', 1)
115
+ expect(chunks).toMatchInlineSnapshot(`
116
+ [
117
+ "15_o",
118
+ "14_n",
119
+ "13_c",
120
+ "12_e",
121
+ "11_ ",
122
+ "10_u",
123
+ "9_p",
124
+ "8_o",
125
+ "7_n",
126
+ "6_ ",
127
+ "5_a",
128
+ "4_ ",
129
+ "3_t",
130
+ "2_i",
131
+ "1_m",
132
+ "0_e",
133
+ ]
134
+ `)
135
+ })
136
+ })
137
+ })
138
+
139
+ const testObject = {} as any
140
+ for (let i = 0; i < 1000; i++) {
141
+ testObject['key_' + i] = 'value_' + i
142
+ }
143
+
144
+ describe('JsonChunkAssembler (CH4–CH8)', () => {
145
+ describe('whole JSON messages while idle (CH4)', () => {
146
+ it('[CH4] parses a message starting with { immediately and returns data and stringified', () => {
147
+ const unchunker = new JsonChunkAssembler()
148
+ expect(unchunker.handleMessage('{"key": "value"}')).toMatchObject({
149
+ data: { key: 'value' },
150
+ stringified: '{"key": "value"}',
151
+ })
152
+ })
153
+
154
+ it('[CH4] handles an empty JSON object', () => {
155
+ const unchunker = new JsonChunkAssembler()
156
+ expect(unchunker.handleMessage('{}')).toMatchObject({ data: {}, stringified: '{}' })
157
+ })
158
+
159
+ it('[CH4] throws synchronously on invalid JSON starting with {', () => {
160
+ const unchunker = new JsonChunkAssembler()
161
+ expect(() => unchunker.handleMessage('{not valid json')).toThrow()
162
+
163
+ // the assembler is still usable afterwards
164
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
165
+ })
166
+ })
167
+
168
+ describe('JSON messages mid-sequence (CH5)', () => {
169
+ it('[CH5] returns an error when a { message interrupts a chunk sequence and resets to idle', () => {
170
+ const unchunker = new JsonChunkAssembler()
171
+
172
+ // start a chunk sequence
173
+ expect(unchunker.handleMessage('2_hello')).toBeNull()
174
+
175
+ // interrupt with a JSON message: both the partial sequence and this
176
+ // message are discarded
177
+ const result = unchunker.handleMessage('{"interrupt": true}')
178
+ assert(result && 'error' in result, 'expected error result')
179
+ expect(result.error.message).toBe('Unexpected non-chunk message')
180
+
181
+ // the assembler reset to idle, so the next message works
182
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
183
+ })
184
+
185
+ it('[CH5] returns an error when the chunk stream ends abruptly', () => {
186
+ const chunks = chunk('{"hello": world"}', 10)
187
+
188
+ const unchunker = new JsonChunkAssembler()
189
+ expect(unchunker.handleMessage(chunks[0])).toBeNull()
190
+ expect(unchunker.handleMessage(chunks[1])).toBeNull()
191
+
192
+ const res = unchunker.handleMessage('{"hello": "world"}')
193
+ assert(res, 'expected a result')
194
+ assert('error' in res, 'expected an error')
195
+ expect(res.error.message).toBe('Unexpected non-chunk message')
196
+
197
+ // and the next one should be fine
198
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
199
+ })
200
+ })
201
+
202
+ describe('chunk accumulation (CH6)', () => {
203
+ it.each([1, 5, 20, 200])(
204
+ '[CH6] returns null until the final chunk, then the parsed json (split at %s bytes)',
205
+ (size) => {
206
+ const chunks = chunk(JSON.stringify(testObject), size)
207
+
208
+ const unchunker = new JsonChunkAssembler()
209
+ for (const c of chunks.slice(0, -1)) {
210
+ expect(unchunker.handleMessage(c)).toBeNull()
211
+ }
212
+ expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
213
+ data: testObject,
214
+ })
215
+
216
+ // resets to idle: the next one should be fine
217
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
218
+ }
219
+ )
220
+
221
+ it('[CH6] handles complex nested JSON objects', () => {
222
+ const complexObject = {
223
+ array: [1, 2, { nested: true }],
224
+ null: null,
225
+ boolean: false,
226
+ number: 3.14,
227
+ string: 'hello world',
228
+ unicode: '🎨🗼️📐',
229
+ deep: {
230
+ nested: {
231
+ object: {
232
+ with: 'many levels',
233
+ },
234
+ },
235
+ },
236
+ }
237
+
238
+ const chunks = chunk(JSON.stringify(complexObject), 50)
239
+ const unchunker = new JsonChunkAssembler()
240
+
241
+ for (const c of chunks.slice(0, -1)) {
242
+ expect(unchunker.handleMessage(c)).toBeNull()
243
+ }
244
+
245
+ expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
246
+ data: complexObject,
247
+ })
248
+ })
249
+
250
+ it('[CH6] handles a single chunk with a 0_ prefix', () => {
251
+ const unchunker = new JsonChunkAssembler()
252
+ expect(unchunker.handleMessage('0_{"single": "chunk"}')).toMatchObject({
253
+ data: { single: 'chunk' },
254
+ stringified: '{"single": "chunk"}',
255
+ })
256
+ })
257
+
258
+ it('[CH6] handles chunks with empty data parts', () => {
259
+ const unchunker = new JsonChunkAssembler()
260
+
261
+ expect(unchunker.handleMessage('1_')).toBeNull() // empty first chunk
262
+ expect(unchunker.handleMessage('0_{"test": true}')).toMatchObject({ data: { test: true } })
263
+ })
264
+
265
+ it('[CH6] returns an error when the assembled json is invalid, then resets to idle', () => {
266
+ const chunks = chunk('{"hello": world"}', 5)
267
+ const unchunker = new JsonChunkAssembler()
268
+ for (const c of chunks.slice(0, -1)) {
269
+ expect(unchunker.handleMessage(c)).toBeNull()
270
+ }
271
+
272
+ const node18Error = `Unexpected token w in JSON at position 10`
273
+ const node20Error = `Unexpected token 'w', "\\{"hello": world"}" is not valid JSON`
274
+ const res = unchunker.handleMessage(chunks[chunks.length - 1])
275
+ assert(res, 'expected a result')
276
+ assert('error' in res, 'expected an error')
277
+ expect(res.error.message).toMatch(new RegExp(`${node18Error}|${node20Error}`))
278
+
279
+ // and the next one should be fine
280
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
281
+ })
282
+
283
+ it('[CH6] returns an error object when chunks form invalid json', () => {
284
+ const unchunker = new JsonChunkAssembler()
285
+
286
+ expect(unchunker.handleMessage('1_{"invalid":')).toBeNull()
287
+ const result = unchunker.handleMessage('0_ }')
288
+
289
+ assert(result && 'error' in result, 'expected error result')
290
+ expect(result.error).toBeInstanceOf(Error)
291
+ })
292
+ })
293
+
294
+ describe('malformed chunk sequences (CH7)', () => {
295
+ it('[CH7] returns an error when a chunk is missing from the sequence, then resets to idle', () => {
296
+ const chunks = chunk('{"hello": world"}', 10)
297
+
298
+ const unchunker = new JsonChunkAssembler()
299
+ expect(unchunker.handleMessage(chunks[0])).toBeNull()
300
+ const res = unchunker.handleMessage(chunks[2])
301
+ assert(res, 'expected a result')
302
+ assert('error' in res, 'expected an error')
303
+ expect(res.error.message).toBe('Chunks received in wrong order')
304
+
305
+ // and the next one should be fine
306
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
307
+ })
308
+
309
+ it('[CH7] returns an error for duplicate or out-of-order chunk numbers', () => {
310
+ const unchunker = new JsonChunkAssembler()
311
+
312
+ expect(unchunker.handleMessage('2_part1')).toBeNull()
313
+
314
+ // the countdown should be 1 here, not 0
315
+ const result = unchunker.handleMessage('0_part3')
316
+ assert(result && 'error' in result, 'expected error result')
317
+ expect(result.error.message).toBe('Chunks received in wrong order')
318
+ })
319
+
320
+ it('[CH7] returns an invalid chunk error for non-json, non-chunk messages', () => {
321
+ const unchunker = new JsonChunkAssembler()
322
+ const res = unchunker.handleMessage('["yo"]')
323
+ assert(res, 'expected a result')
324
+ assert('error' in res, 'expected an error')
325
+ expect(res.error.message).toContain('Invalid chunk')
326
+
327
+ // and the next one should be fine
328
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
329
+ })
330
+
331
+ it('[CH7] returns an invalid chunk error for a malformed chunk number', () => {
332
+ const unchunker = new JsonChunkAssembler()
333
+ const result = unchunker.handleMessage('abc_invalid_number')
334
+ assert(result && 'error' in result, 'expected error result')
335
+ expect(result.error.message).toContain('Invalid chunk')
336
+ })
337
+
338
+ it('[CH7] resets to idle after an invalid chunk mid-sequence', () => {
339
+ const unchunker = new JsonChunkAssembler()
340
+
341
+ // start a chunk sequence
342
+ expect(unchunker.handleMessage('1_hello')).toBeNull()
343
+
344
+ // send a malformed chunk to trigger the error
345
+ const result = unchunker.handleMessage('invalid_chunk_format')
346
+ assert(result && 'error' in result, 'expected error result')
347
+ expect(result.error.message).toContain('Invalid chunk')
348
+
349
+ // normal messages work again
350
+ expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
351
+ })
352
+ })
353
+
354
+ describe('chunk body contents (CH8)', () => {
355
+ it('[CH8] handles unicode line separators U+2028 and U+2029 in chunks', () => {
356
+ // These characters are common when copy/pasting from Microsoft Word
357
+ // and are valid JSON string content but act as line terminators in JS
358
+ const unchunker = new JsonChunkAssembler()
359
+
360
+ const jsonWithLineSeparators = '{"text": "hello\u2028world\u2029end"}'
361
+ const chunks = chunk(jsonWithLineSeparators, 10)
362
+
363
+ for (const c of chunks.slice(0, -1)) {
364
+ expect(unchunker.handleMessage(c)).toBeNull()
365
+ }
366
+
367
+ expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
368
+ data: { text: 'hello\u2028world\u2029end' },
369
+ })
370
+ })
371
+ })
372
+ })