@tldraw/sync-core 5.2.0-next.79b13319d317 → 5.2.0-next.b0c9cf86de67

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 (48) hide show
  1. package/README.md +4 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  4. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  5. package/dist-cjs/lib/TLSyncClient.js +2 -3
  6. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncRoom.js +27 -3
  8. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  9. package/dist-esm/index.mjs +1 -1
  10. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  12. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  13. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  14. package/dist-esm/lib/TLSyncRoom.mjs +27 -3
  15. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  16. package/package.json +6 -6
  17. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  18. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  19. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  20. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  21. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  22. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  23. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  24. package/src/lib/TLSocketRoom.ts +6 -1
  25. package/src/lib/TLSyncClient.test.ts +1127 -846
  26. package/src/lib/TLSyncClient.ts +4 -4
  27. package/src/lib/TLSyncRoom.ts +34 -4
  28. package/src/lib/TLSyncStorage.test.ts +225 -0
  29. package/src/lib/chunk.test.ts +372 -0
  30. package/src/lib/diff.test.ts +885 -0
  31. package/src/lib/interval.test.ts +43 -0
  32. package/src/lib/protocol.test.ts +43 -0
  33. package/src/lib/recordDiff.test.ts +159 -0
  34. package/src/test/TLSocketRoom.test.ts +1109 -894
  35. package/src/test/TLSyncRoom.test.ts +1754 -870
  36. package/src/test/storageContractSuite.ts +618 -0
  37. package/src/test/upgradeDowngrade.test.ts +140 -40
  38. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  39. package/src/lib/RoomSession.test.ts +0 -101
  40. package/src/lib/computeTombstonePruning.test.ts +0 -352
  41. package/src/lib/server-types.test.ts +0 -44
  42. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  43. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  44. package/src/test/chunk.test.ts +0 -385
  45. package/src/test/customMessages.test.ts +0 -36
  46. package/src/test/diff.test.ts +0 -784
  47. package/src/test/presenceMode.test.ts +0 -149
  48. 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
 
@@ -835,6 +838,28 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
835
838
  this.broadcastPatch(diff)
836
839
  }
837
840
 
841
+ /**
842
+ * Work out whether a client we can't reconcile schemas with is running a newer or older SDK
843
+ * than us.
844
+ */
845
+ private getVersionMismatchReason(theirSchema: SerializedSchema) {
846
+ const ourSchema = this.serializedSchema
847
+
848
+ if (theirSchema.schemaVersion > ourSchema.schemaVersion) {
849
+ return TLSyncErrorCloseEventReason.SERVER_TOO_OLD
850
+ }
851
+
852
+ if (theirSchema.schemaVersion === 2 && ourSchema.schemaVersion === 2) {
853
+ for (const [sequenceId, theirVersion] of Object.entries(theirSchema.sequences)) {
854
+ const ourVersion = ourSchema.sequences[sequenceId]
855
+ if (ourVersion === undefined || theirVersion > ourVersion) {
856
+ return TLSyncErrorCloseEventReason.SERVER_TOO_OLD
857
+ }
858
+ }
859
+ }
860
+ return TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
861
+ }
862
+
838
863
  private handleConnectRequest(
839
864
  session: RoomSession<R, SessionMeta>,
840
865
  message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
@@ -871,8 +896,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
871
896
  return
872
897
  }
873
898
  const migrations = this.schema.getMigrationsSince(message.schema)
874
- // if the client's store is at a different version to ours, we can't support them
875
- if (!migrations.ok || migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
899
+ if (!migrations.ok) {
900
+ this.rejectSession(session.sessionId, this.getVersionMismatchReason(message.schema))
901
+ return
902
+ }
903
+ // The client's schema is older than ours, but we can't migrate our data down to their
904
+ // version (a migration isn't record-scoped or has no down migration), so they're too old.
905
+ if (migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
876
906
  this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
877
907
  return
878
908
  }
@@ -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
+ })