@tldraw/sync-core 5.2.0-canary.4a316fdfb2bb → 5.2.0-canary.4e00299c3e28

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
@@ -0,0 +1,239 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
3
+ import { ZERO_INDEX_KEY } from '@tldraw/utils'
4
+ import { describe, expect, it } from 'vitest'
5
+ import {
6
+ contractRecords,
7
+ contractSchema,
8
+ makeContractSnapshot,
9
+ makePage,
10
+ registerStorageContractTests,
11
+ } from '../test/storageContractSuite'
12
+ import { NodeSqliteWrapper } from './NodeSqliteWrapper'
13
+ import { SQLiteSyncStorage } from './SQLiteSyncStorage'
14
+
15
+ function createWrapper(config?: { tablePrefix?: string }) {
16
+ return new NodeSqliteWrapper(new DatabaseSync(':memory:'), config)
17
+ }
18
+
19
+ describe('SQLiteSyncStorage', () => {
20
+ registerStorageContractTests({
21
+ create: (opts) => new SQLiteSyncStorage<TLRecord>({ sql: createWrapper(), ...opts }),
22
+ })
23
+
24
+ describe('persistence and initialization', () => {
25
+ it('[SQ1] data persists across re-instantiation over the same database', () => {
26
+ const sql = createWrapper()
27
+ const storage = new SQLiteSyncStorage<TLRecord>({
28
+ sql,
29
+ snapshot: makeContractSnapshot(contractRecords),
30
+ })
31
+ const newPage = makePage('persisted')
32
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
33
+
34
+ const reopened = new SQLiteSyncStorage<TLRecord>({ sql })
35
+ expect(reopened.getClock()).toBe(1)
36
+ expect(
37
+ reopened.getSnapshot().documents.find((d) => d.state.id === newPage.id)?.state
38
+ ).toEqual(newPage)
39
+ })
40
+
41
+ it('[SQ1] honors the table prefix', () => {
42
+ const sql = createWrapper({ tablePrefix: 'tldraw_' })
43
+ const storage = new SQLiteSyncStorage<TLRecord>({
44
+ sql,
45
+ snapshot: makeContractSnapshot(contractRecords, { documentClock: 7 }),
46
+ })
47
+ expect(storage.getClock()).toBe(7)
48
+ // the prefixed tables exist; the unprefixed ones do not
49
+ expect(sql.prepare('SELECT count(*) as count FROM tldraw_documents').all()).toEqual([
50
+ { count: 2 },
51
+ ])
52
+ expect(() => sql.prepare('SELECT count(*) as count FROM documents').all()).toThrow()
53
+ })
54
+
55
+ it('[SQ2] a provided snapshot wipes and replaces existing data', () => {
56
+ const sql = createWrapper()
57
+ const storage = new SQLiteSyncStorage<TLRecord>({
58
+ sql,
59
+ snapshot: makeContractSnapshot(contractRecords, { documentClock: 10 }),
60
+ })
61
+ const extra = makePage('extra')
62
+ storage.transaction((txn) => txn.set(extra.id, extra))
63
+
64
+ const replacement = makeContractSnapshot([contractRecords[0]], { documentClock: 3 })
65
+ const reinitialized = new SQLiteSyncStorage<TLRecord>({ sql, snapshot: replacement })
66
+ expect(reinitialized.getClock()).toBe(3)
67
+ expect(reinitialized.getSnapshot().documents.map((d) => d.state.id)).toEqual([
68
+ contractRecords[0].id,
69
+ ])
70
+ })
71
+
72
+ it('[SQ2] without a snapshot, pre-existing data is kept untouched', () => {
73
+ const sql = createWrapper()
74
+ new SQLiteSyncStorage<TLRecord>({
75
+ sql,
76
+ snapshot: makeContractSnapshot(contractRecords, { documentClock: 9 }),
77
+ })
78
+ const reopened = new SQLiteSyncStorage<TLRecord>({ sql })
79
+ expect(reopened.getClock()).toBe(9)
80
+ expect(reopened.getSnapshot().documents.length).toBe(2)
81
+ })
82
+
83
+ it('[SQ2] accepts a StoreSnapshot and converts it', () => {
84
+ const sql = createWrapper()
85
+ const storage = new SQLiteSyncStorage<TLRecord>({
86
+ sql,
87
+ snapshot: {
88
+ store: {
89
+ [TLDOCUMENT_ID]: contractRecords[0],
90
+ [contractRecords[1].id]: contractRecords[1],
91
+ } as any,
92
+ schema: contractSchema.serialize(),
93
+ },
94
+ })
95
+ const snapshot = storage.getSnapshot()
96
+ expect(snapshot.documentClock).toBe(0)
97
+ expect(snapshot.documents.length).toBe(2)
98
+ expect(snapshot.documents.every((d) => d.lastChangedClock === 0)).toBe(true)
99
+ expect(snapshot.tombstones).toEqual({})
100
+ })
101
+ })
102
+
103
+ describe('clock handling vs the in-memory implementation', () => {
104
+ it('[SQ3] takes documentClock verbatim, without clamping to lastChangedClock', () => {
105
+ const storage = new SQLiteSyncStorage<TLRecord>({
106
+ sql: createWrapper(),
107
+ snapshot: makeContractSnapshot(contractRecords, {
108
+ documents: [
109
+ { state: contractRecords[0], lastChangedClock: 10 },
110
+ { state: contractRecords[1], lastChangedClock: 3 },
111
+ ],
112
+ documentClock: 3,
113
+ }),
114
+ })
115
+ expect(storage.getClock()).toBe(3)
116
+ })
117
+
118
+ it('[SQ3] takes tombstoneHistoryStartsAtClock verbatim and keeps tombstones with an empty history window', () => {
119
+ const storage = new SQLiteSyncStorage<TLRecord>({
120
+ sql: createWrapper(),
121
+ snapshot: makeContractSnapshot(contractRecords, {
122
+ tombstones: { 'shape:deleted1': 5 },
123
+ tombstoneHistoryStartsAtClock: 15,
124
+ documentClock: 15,
125
+ }),
126
+ })
127
+ const snapshot = storage.getSnapshot()
128
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(15)
129
+ expect(snapshot.tombstones).toEqual({ 'shape:deleted1': 5 })
130
+ })
131
+ })
132
+
133
+ describe('static methods', () => {
134
+ it('[SQ4] hasBeenInitialized is false for an empty database and true once initialized', () => {
135
+ const sql = createWrapper()
136
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
137
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
138
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
139
+ })
140
+
141
+ it('[SQ4] hasBeenInitialized respects the table prefix', () => {
142
+ const sql = createWrapper({ tablePrefix: 'test_' })
143
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
144
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
145
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
146
+ })
147
+
148
+ it('[SQ5] getDocumentClock returns null when uninitialized, the persisted clock afterwards', () => {
149
+ const sql = createWrapper()
150
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
151
+
152
+ const storage = new SQLiteSyncStorage<TLRecord>({
153
+ sql,
154
+ snapshot: makeContractSnapshot(contractRecords, { documentClock: 42 }),
155
+ })
156
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(42)
157
+
158
+ const newPage = makePage('test_page')
159
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
160
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(43)
161
+ })
162
+ })
163
+
164
+ describe('storage format migrations', () => {
165
+ it('[SQ6] stores document state as JSON-encoded BLOBs', () => {
166
+ const sql = createWrapper()
167
+ new SQLiteSyncStorage<TLRecord>({
168
+ sql,
169
+ snapshot: makeContractSnapshot(contractRecords),
170
+ })
171
+ const row = sql
172
+ .prepare<{ state: Uint8Array }>('SELECT state FROM documents WHERE id = ?')
173
+ .all(TLDOCUMENT_ID as any)[0]
174
+ // node:sqlite may hand back a Uint8Array from another realm, so avoid instanceof
175
+ expect(typeof row.state).not.toBe('string')
176
+ expect(ArrayBuffer.isView(row.state)).toBe(true)
177
+ expect(JSON.parse(new TextDecoder().decode(row.state))).toEqual(contractRecords[0])
178
+ })
179
+
180
+ it('[SQ6] migrates v1 databases (TEXT column) to BLOB, preserving data', () => {
181
+ // simulate a database created by the v1 schema
182
+ const db = new DatabaseSync(':memory:')
183
+ db.exec(`
184
+ CREATE TABLE documents (
185
+ id TEXT PRIMARY KEY,
186
+ state TEXT NOT NULL,
187
+ lastChangedClock INTEGER NOT NULL
188
+ );
189
+ CREATE INDEX idx_documents_lastChangedClock ON documents(lastChangedClock);
190
+ CREATE TABLE tombstones (
191
+ id TEXT PRIMARY KEY,
192
+ clock INTEGER NOT NULL
193
+ );
194
+ CREATE INDEX idx_tombstones_clock ON tombstones(clock);
195
+ CREATE TABLE metadata (
196
+ migrationVersion INTEGER NOT NULL,
197
+ documentClock INTEGER NOT NULL,
198
+ tombstoneHistoryStartsAtClock INTEGER NOT NULL,
199
+ schema TEXT NOT NULL
200
+ );
201
+ INSERT INTO metadata (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema)
202
+ VALUES (1, 5, 0, '${JSON.stringify(contractSchema.serialize()).replace(/'/g, "''")}');
203
+ `)
204
+ const doc = DocumentRecordType.create({ id: TLDOCUMENT_ID })
205
+ const page = PageRecordType.create({
206
+ id: PageRecordType.createId('migrated_page'),
207
+ name: 'Migrated Page',
208
+ index: ZERO_INDEX_KEY,
209
+ })
210
+ const insert = db.prepare(
211
+ 'INSERT INTO documents (id, state, lastChangedClock) VALUES (?, ?, ?)'
212
+ )
213
+ insert.run(doc.id, JSON.stringify(doc), 1)
214
+ insert.run(page.id, JSON.stringify(page), 2)
215
+ db.exec("INSERT INTO tombstones (id, clock) VALUES ('shape:deleted', 3)")
216
+
217
+ const storage = new SQLiteSyncStorage<TLRecord>({ sql: new NodeSqliteWrapper(db) })
218
+
219
+ const snapshot = storage.getSnapshot()
220
+ expect(snapshot.documentClock).toBe(5)
221
+ expect(snapshot.documents.map((d) => d.state.id).sort()).toEqual([doc.id, page.id].sort())
222
+ expect(snapshot.tombstones).toEqual({ 'shape:deleted': 3 })
223
+
224
+ // still writable after migration
225
+ const newPage = makePage('new_after_migration')
226
+ storage.transaction((txn) => txn.set(newPage.id, newPage))
227
+ expect(storage.getSnapshot().documents.length).toBe(3)
228
+ })
229
+
230
+ it('[SQ6] fresh databases start at migration version 2', () => {
231
+ const sql = createWrapper()
232
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
233
+ const row = sql
234
+ .prepare<{ migrationVersion: number }>('SELECT migrationVersion FROM metadata')
235
+ .all()[0]
236
+ expect(row?.migrationVersion).toBe(2)
237
+ })
238
+ })
239
+ })
@@ -3,226 +3,87 @@ import { describe, expect, it, vi } from 'vitest'
3
3
  import { TLSocketServerSentEvent } from './protocol'
4
4
  import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
5
5
 
6
- // Mock WebSocket implementations for testing different scenarios
7
6
  class MockWebSocket implements WebSocketMinimal {
8
- readyState: number = 1 // OPEN by default
7
+ readyState = 1 // OPEN
9
8
  send = vi.fn()
10
9
  close = vi.fn()
11
- addEventListener = vi.fn()
12
- removeEventListener = vi.fn()
13
10
  }
14
11
 
15
- class MinimalMockWebSocket implements WebSocketMinimal {
16
- readyState: number = 1
17
- send = vi.fn()
18
- close = vi.fn()
19
- // No addEventListener/removeEventListener
20
- }
12
+ const pongMessage: TLSocketServerSentEvent<UnknownRecord> = { type: 'pong' }
21
13
 
22
14
  describe('ServerSocketAdapter', () => {
23
- describe('sendMessage', () => {
24
- it('should JSON stringify and send the message', () => {
25
- const mockWs = new MockWebSocket()
26
- const adapter = new ServerSocketAdapter({ ws: mockWs })
27
-
28
- const message: TLSocketServerSentEvent<UnknownRecord> = {
29
- type: 'connect',
30
- hydrationType: 'wipe_all',
31
- connectRequestId: 'test-request-123',
32
- protocolVersion: 1,
33
- schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
34
- diff: {},
35
- serverClock: 0,
36
- isReadonly: false,
37
- }
38
-
39
- adapter.sendMessage(message)
40
-
41
- expect(mockWs.send).toHaveBeenCalledTimes(1)
42
- expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(message))
43
- })
44
-
45
- it('should call onBeforeSendMessage callback when provided', () => {
46
- const mockWs = new MockWebSocket()
47
- const onBeforeSendMessage = vi.fn()
48
- const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
49
-
50
- const message: TLSocketServerSentEvent<UnknownRecord> = {
51
- type: 'data',
52
- data: [{ type: 'patch', diff: {}, serverClock: 1 }],
53
- }
54
-
55
- adapter.sendMessage(message)
56
-
57
- expect(onBeforeSendMessage).toHaveBeenCalledTimes(1)
58
- expect(onBeforeSendMessage).toHaveBeenCalledWith(message, JSON.stringify(message))
59
- expect(mockWs.send).toHaveBeenCalledTimes(1)
60
- })
61
-
62
- it('should call onBeforeSendMessage before sending to WebSocket', () => {
63
- const mockWs = new MockWebSocket()
64
- const callOrder: string[] = []
65
-
66
- const onBeforeSendMessage = vi.fn(() => {
67
- callOrder.push('callback')
68
- })
69
-
70
- mockWs.send.mockImplementation(() => {
71
- callOrder.push('websocket')
72
- })
73
-
74
- const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
75
-
76
- const message: TLSocketServerSentEvent<UnknownRecord> = {
77
- type: 'pong',
78
- }
79
-
80
- adapter.sendMessage(message)
81
-
82
- expect(callOrder).toEqual(['callback', 'websocket'])
83
- })
15
+ it('[SA1] isOpen is true exactly when the wrapped socket readyState is 1', () => {
16
+ const ws = new MockWebSocket()
17
+ const adapter = new ServerSocketAdapter({ ws })
18
+
19
+ ws.readyState = 0 // CONNECTING
20
+ expect(adapter.isOpen).toBe(false)
21
+ ws.readyState = 1 // OPEN
22
+ expect(adapter.isOpen).toBe(true)
23
+ ws.readyState = 2 // CLOSING
24
+ expect(adapter.isOpen).toBe(false)
25
+ ws.readyState = 3 // CLOSED
26
+ expect(adapter.isOpen).toBe(false)
84
27
  })
85
28
 
86
- describe('close method', () => {
87
- it('should call close on the underlying WebSocket with no parameters', () => {
88
- const mockWs = new MockWebSocket()
89
- const adapter = new ServerSocketAdapter({ ws: mockWs })
90
-
91
- adapter.close()
92
-
93
- expect(mockWs.close).toHaveBeenCalledTimes(1)
94
- expect(mockWs.close).toHaveBeenCalledWith(undefined, undefined)
95
- })
96
-
97
- it('should call close on the underlying WebSocket with code only', () => {
98
- const mockWs = new MockWebSocket()
99
- const adapter = new ServerSocketAdapter({ ws: mockWs })
100
-
101
- const closeCode = 1000
102
- adapter.close(closeCode)
103
-
104
- expect(mockWs.close).toHaveBeenCalledTimes(1)
105
- expect(mockWs.close).toHaveBeenCalledWith(closeCode, undefined)
106
- })
107
-
108
- it('should call close on the underlying WebSocket with code and reason', () => {
109
- const mockWs = new MockWebSocket()
110
- const adapter = new ServerSocketAdapter({ ws: mockWs })
111
-
112
- const closeCode = 1001
113
- const closeReason = 'Going away'
114
- adapter.close(closeCode, closeReason)
115
-
116
- expect(mockWs.close).toHaveBeenCalledTimes(1)
117
- expect(mockWs.close).toHaveBeenCalledWith(closeCode, closeReason)
118
- })
29
+ it('[SA2] sendMessage JSON-stringifies the message and sends it', () => {
30
+ const ws = new MockWebSocket()
31
+ const adapter = new ServerSocketAdapter({ ws })
32
+
33
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
34
+ type: 'connect',
35
+ hydrationType: 'wipe_all',
36
+ connectRequestId: 'test-request-123',
37
+ protocolVersion: 1,
38
+ schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
39
+ diff: {},
40
+ serverClock: 0,
41
+ isReadonly: false,
42
+ }
43
+
44
+ adapter.sendMessage(message)
45
+
46
+ expect(ws.send).toHaveBeenCalledTimes(1)
47
+ expect(ws.send).toHaveBeenCalledWith(JSON.stringify(message))
119
48
  })
120
49
 
121
- describe('Error handling', () => {
122
- it('should handle WebSocket send errors gracefully', () => {
123
- const mockWs = new MockWebSocket()
124
- mockWs.send.mockImplementation(() => {
125
- throw new Error('WebSocket send failed')
126
- })
127
-
128
- const adapter = new ServerSocketAdapter({ ws: mockWs })
129
-
130
- const message: TLSocketServerSentEvent<UnknownRecord> = {
131
- type: 'pong',
132
- }
133
-
134
- expect(() => adapter.sendMessage(message)).toThrow('WebSocket send failed')
50
+ it('[SA2] sendMessage invokes onBeforeSendMessage with the message and its stringified form before sending', () => {
51
+ const ws = new MockWebSocket()
52
+ const callOrder: string[] = []
53
+ const onBeforeSendMessage = vi.fn(() => {
54
+ callOrder.push('callback')
135
55
  })
136
-
137
- it('should handle WebSocket close errors gracefully', () => {
138
- const mockWs = new MockWebSocket()
139
- mockWs.close.mockImplementation(() => {
140
- throw new Error('WebSocket close failed')
141
- })
142
-
143
- const adapter = new ServerSocketAdapter({ ws: mockWs })
144
-
145
- expect(() => adapter.close()).toThrow('WebSocket close failed')
56
+ ws.send.mockImplementation(() => {
57
+ callOrder.push('send')
146
58
  })
59
+ const adapter = new ServerSocketAdapter({ ws, onBeforeSendMessage })
147
60
 
148
- it('should handle onBeforeSendMessage callback errors', () => {
149
- const mockWs = new MockWebSocket()
150
- const faultyCallback = vi.fn(() => {
151
- throw new Error('Callback error')
152
- })
153
-
154
- const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage: faultyCallback })
155
-
156
- const message: TLSocketServerSentEvent<UnknownRecord> = {
157
- type: 'pong',
158
- }
61
+ adapter.sendMessage(pongMessage)
159
62
 
160
- expect(() => adapter.sendMessage(message)).toThrow('Callback error')
161
- expect(faultyCallback).toHaveBeenCalled()
162
- // WebSocket send should not be called if callback throws
163
- expect(mockWs.send).not.toHaveBeenCalled()
164
- })
63
+ expect(onBeforeSendMessage).toHaveBeenCalledTimes(1)
64
+ expect(onBeforeSendMessage).toHaveBeenCalledWith(pongMessage, JSON.stringify(pongMessage))
65
+ expect(ws.send).toHaveBeenCalledTimes(1)
66
+ expect(callOrder).toEqual(['callback', 'send'])
165
67
  })
166
68
 
167
- describe('Integration scenarios', () => {
168
- it('should work with different WebSocket implementations', () => {
169
- const scenarios = [
170
- { name: 'Standard WebSocket', ws: new MockWebSocket() },
171
- { name: 'Minimal WebSocket', ws: new MinimalMockWebSocket() },
172
- ]
173
-
174
- scenarios.forEach(({ name, ws }) => {
175
- const adapter = new ServerSocketAdapter({ ws })
176
-
177
- expect(adapter, `${name} should create adapter`).toBeInstanceOf(ServerSocketAdapter)
69
+ it('[SA3] close passes through to the wrapped socket without arguments', () => {
70
+ const ws = new MockWebSocket()
71
+ const adapter = new ServerSocketAdapter({ ws })
178
72
 
179
- const message: TLSocketServerSentEvent<UnknownRecord> = {
180
- type: 'pong',
181
- }
73
+ adapter.close()
182
74
 
183
- expect(
184
- () => adapter.sendMessage(message),
185
- `${name} should handle sendMessage`
186
- ).not.toThrow()
187
- expect(() => adapter.close(), `${name} should handle close`).not.toThrow()
188
- })
189
- })
190
-
191
- it('should handle rapid message sending', () => {
192
- const mockWs = new MockWebSocket()
193
- const onBeforeSendMessage = vi.fn()
194
- const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
195
-
196
- // Send multiple messages rapidly
197
- for (let i = 0; i < 100; i++) {
198
- const message: TLSocketServerSentEvent<UnknownRecord> = {
199
- type: 'pong',
200
- }
201
- adapter.sendMessage(message)
202
- }
203
-
204
- expect(mockWs.send).toHaveBeenCalledTimes(100)
205
- expect(onBeforeSendMessage).toHaveBeenCalledTimes(100)
206
- })
207
-
208
- it('should maintain consistent behavior across state changes', () => {
209
- const mockWs = new MockWebSocket()
210
- const adapter = new ServerSocketAdapter({ ws: mockWs })
211
-
212
- // Test behavior when WebSocket changes state
213
- expect(adapter.isOpen).toBe(true)
214
-
215
- mockWs.readyState = 0 // CONNECTING
216
- expect(adapter.isOpen).toBe(false)
75
+ expect(ws.close).toHaveBeenCalledTimes(1)
76
+ expect(ws.close).toHaveBeenCalledWith(undefined, undefined)
77
+ })
217
78
 
218
- mockWs.readyState = 2 // CLOSING
219
- expect(adapter.isOpen).toBe(false)
79
+ it('[SA3] close passes through the code and reason to the wrapped socket', () => {
80
+ const ws = new MockWebSocket()
81
+ const adapter = new ServerSocketAdapter({ ws })
220
82
 
221
- mockWs.readyState = 3 // CLOSED
222
- expect(adapter.isOpen).toBe(false)
83
+ adapter.close(1000)
84
+ expect(ws.close).toHaveBeenLastCalledWith(1000, undefined)
223
85
 
224
- mockWs.readyState = 1 // OPEN
225
- expect(adapter.isOpen).toBe(true)
226
- })
86
+ adapter.close(1001, 'Going away')
87
+ expect(ws.close).toHaveBeenLastCalledWith(1001, 'Going away')
227
88
  })
228
89
  })
@@ -427,7 +427,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
427
427
  }
428
428
  }
429
429
 
430
- this.room.handleMessage(sessionId, res.data as any)
430
+ // handleMessage is async but does not await internally; catch rejections
431
+ // so an error thrown while handling the message still rejects the session
432
+ this.room.handleMessage(sessionId, res.data as any).catch((error) => {
433
+ this.log?.error?.(error)
434
+ this.room.rejectSession(sessionId, TLSyncErrorCloseEventReason.UNKNOWN_ERROR)
435
+ })
431
436
  this.room.pruneSessions()
432
437
  this.scheduleDebouncedSnapshot(sessionId)
433
438
  } else {