@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.
- package/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +3 -2
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +3 -2
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +9 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +5 -2
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1735 -893
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +137 -37
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- 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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
type: 'pong',
|
|
181
|
-
}
|
|
73
|
+
adapter.close()
|
|
182
74
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
83
|
+
adapter.close(1000)
|
|
84
|
+
expect(ws.close).toHaveBeenLastCalledWith(1000, undefined)
|
|
223
85
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
})
|
|
86
|
+
adapter.close(1001, 'Going away')
|
|
87
|
+
expect(ws.close).toHaveBeenLastCalledWith(1001, 'Going away')
|
|
227
88
|
})
|
|
228
89
|
})
|
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -427,7 +427,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
427
427
|
}
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
|
|
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 {
|