@tldraw/sync-core 4.2.0 → 4.2.2
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/dist-cjs/index.d.ts +483 -58
- package/dist-cjs/index.js +13 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +55 -0
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +7 -0
- package/dist-cjs/lib/InMemorySyncStorage.js +287 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/MicrotaskNotifier.js +50 -0
- package/dist-cjs/lib/MicrotaskNotifier.js.map +7 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js +48 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +428 -0
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +7 -0
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +483 -58
- package/dist-esm/index.mjs +20 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +35 -0
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +7 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs +272 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs +30 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +7 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs +28 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +414 -0
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +12 -11
- package/src/index.ts +32 -3
- package/src/lib/ClientWebSocketAdapter.test.ts +3 -0
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +95 -0
- package/src/lib/InMemorySyncStorage.ts +387 -0
- package/src/lib/MicrotaskNotifier.test.ts +429 -0
- package/src/lib/MicrotaskNotifier.ts +38 -0
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +270 -0
- package/src/lib/NodeSqliteSyncWrapper.test.ts +272 -0
- package/src/lib/NodeSqliteWrapper.ts +99 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/SQLiteSyncStorage.ts +627 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/chunk.ts +2 -2
- package/src/lib/computeTombstonePruning.test.ts +352 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/FuzzEditor.ts +4 -5
- package/src/test/InMemorySyncStorage.test.ts +1684 -0
- package/src/test/SQLiteSyncStorage.test.ts +1378 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1024 -534
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/syncFuzz.test.ts +2 -4
- package/src/test/upgradeDowngrade.test.ts +290 -8
- package/src/test/validation.test.ts +15 -10
- package/src/test/pruneTombstones.test.ts +0 -178
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import { RecordId, StoreSchema } from 'tldraw'
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { NodeSqliteWrapper } from './NodeSqliteWrapper'
|
|
5
|
+
import { migrateSqliteSyncStorage, SQLiteSyncStorage } from './SQLiteSyncStorage'
|
|
6
|
+
import { RoomSnapshot } from './TLSyncRoom'
|
|
7
|
+
|
|
8
|
+
// Simple record type for testing
|
|
9
|
+
interface TestRecord {
|
|
10
|
+
id: RecordId<TestRecord>
|
|
11
|
+
typeName: string
|
|
12
|
+
name: string
|
|
13
|
+
value: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ID = RecordId<TestRecord>
|
|
17
|
+
|
|
18
|
+
// SQLiteSyncStorage uses multi-statement DDL in constructor which node:sqlite
|
|
19
|
+
// doesn't support (prepare() only handles one statement). We need to initialize
|
|
20
|
+
// the tables separately before creating the storage.
|
|
21
|
+
function initializeTables(db: DatabaseSync) {
|
|
22
|
+
migrateSqliteSyncStorage(new NodeSqliteWrapper(db))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultSnapshot: RoomSnapshot = {
|
|
26
|
+
documents: [],
|
|
27
|
+
tombstones: {},
|
|
28
|
+
schema: StoreSchema.create({}).serialize(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('NodeSqliteSyncWrapper + SQLiteSyncStorage integration', () => {
|
|
32
|
+
let db: DatabaseSync
|
|
33
|
+
let sql: NodeSqliteWrapper
|
|
34
|
+
let storage: SQLiteSyncStorage<TestRecord>
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
db = new DatabaseSync(':memory:')
|
|
38
|
+
initializeTables(db)
|
|
39
|
+
sql = new NodeSqliteWrapper(db)
|
|
40
|
+
// Pass undefined snapshot since we already initialized the tables
|
|
41
|
+
storage = new SQLiteSyncStorage<TestRecord>({
|
|
42
|
+
sql,
|
|
43
|
+
snapshot: defaultSnapshot,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('basic operations', () => {
|
|
48
|
+
it('can set and get a record', () => {
|
|
49
|
+
const record: TestRecord = { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 }
|
|
50
|
+
|
|
51
|
+
storage.transaction((txn) => {
|
|
52
|
+
txn.set(record.id, record)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const result = storage.transaction((txn) => txn.get('test-1'))
|
|
56
|
+
expect(result.result).toEqual(record)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('can delete a record', () => {
|
|
60
|
+
const record: TestRecord = { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 }
|
|
61
|
+
|
|
62
|
+
storage.transaction((txn) => {
|
|
63
|
+
txn.set(record.id, record)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
storage.transaction((txn) => {
|
|
67
|
+
txn.delete('test-1')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const result = storage.transaction((txn) => txn.get('test-1'))
|
|
71
|
+
expect(result.result).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('increments clock on mutations', () => {
|
|
75
|
+
expect(storage.getClock()).toBe(0)
|
|
76
|
+
|
|
77
|
+
storage.transaction((txn) => {
|
|
78
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(storage.getClock()).toBe(1)
|
|
82
|
+
|
|
83
|
+
storage.transaction((txn) => {
|
|
84
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(storage.getClock()).toBe(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('only increments clock once per transaction', () => {
|
|
91
|
+
storage.transaction((txn) => {
|
|
92
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
93
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
94
|
+
txn.set('test-3', { id: 'test-3' as ID, typeName: 'test', name: 'Charlie', value: 300 })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(storage.getClock()).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('iteration', () => {
|
|
102
|
+
it('can iterate over entries', () => {
|
|
103
|
+
storage.transaction((txn) => {
|
|
104
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
105
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const entries = storage.transaction((txn) => [...txn.entries()])
|
|
109
|
+
expect(entries.result).toHaveLength(2)
|
|
110
|
+
expect(entries.result.map(([id]) => id).sort()).toEqual(['test-1', 'test-2'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('can iterate over keys', () => {
|
|
114
|
+
storage.transaction((txn) => {
|
|
115
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
116
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const keys = storage.transaction((txn) => [...txn.keys()])
|
|
120
|
+
expect(keys.result.sort()).toEqual(['test-1', 'test-2'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('can iterate over values', () => {
|
|
124
|
+
storage.transaction((txn) => {
|
|
125
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
126
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const values = storage.transaction((txn) => [...txn.values()])
|
|
130
|
+
expect(values.result).toHaveLength(2)
|
|
131
|
+
expect(values.result.map((v) => v.name).sort()).toEqual(['Alice', 'Bob'])
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('snapshots', () => {
|
|
136
|
+
it('can get a snapshot', () => {
|
|
137
|
+
storage.transaction((txn) => {
|
|
138
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const snapshot = storage.getSnapshot()
|
|
142
|
+
|
|
143
|
+
expect(snapshot.documentClock).toBe(1)
|
|
144
|
+
expect(snapshot.documents).toHaveLength(1)
|
|
145
|
+
expect(snapshot.documents[0].state).toEqual({
|
|
146
|
+
id: 'test-1',
|
|
147
|
+
typeName: 'test',
|
|
148
|
+
name: 'Alice',
|
|
149
|
+
value: 100,
|
|
150
|
+
})
|
|
151
|
+
expect(snapshot.schema).toEqual(defaultSnapshot.schema)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('includes tombstones in snapshot', () => {
|
|
155
|
+
storage.transaction((txn) => {
|
|
156
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
storage.transaction((txn) => {
|
|
160
|
+
txn.delete('test-1')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const snapshot = storage.getSnapshot()
|
|
164
|
+
|
|
165
|
+
expect(snapshot.documents).toHaveLength(0)
|
|
166
|
+
expect(snapshot.tombstones).toEqual({ 'test-1': 2 })
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('getChangesSince', () => {
|
|
171
|
+
it('returns changes since a given clock', () => {
|
|
172
|
+
storage.transaction((txn) => {
|
|
173
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const clock1 = storage.getClock()
|
|
177
|
+
|
|
178
|
+
storage.transaction((txn) => {
|
|
179
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const changes = storage.transaction((txn) => txn.getChangesSince(clock1))
|
|
183
|
+
|
|
184
|
+
expect(changes.result?.diff.puts).toHaveProperty('test-2')
|
|
185
|
+
expect(changes.result?.diff.puts).not.toHaveProperty('test-1')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('includes deletes in changes', () => {
|
|
189
|
+
storage.transaction((txn) => {
|
|
190
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const clock1 = storage.getClock()
|
|
194
|
+
|
|
195
|
+
storage.transaction((txn) => {
|
|
196
|
+
txn.delete('test-1')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const changes = storage.transaction((txn) => txn.getChangesSince(clock1))
|
|
200
|
+
|
|
201
|
+
expect(changes.result?.diff.deletes).toContain('test-1')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('returns undefined when no changes', () => {
|
|
205
|
+
storage.transaction((txn) => {
|
|
206
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const clock = storage.getClock()
|
|
210
|
+
|
|
211
|
+
const changes = storage.transaction((txn) => txn.getChangesSince(clock))
|
|
212
|
+
|
|
213
|
+
expect(changes.result).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('transaction rollback', () => {
|
|
218
|
+
it('rolls back on error', () => {
|
|
219
|
+
storage.transaction((txn) => {
|
|
220
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(() => {
|
|
224
|
+
storage.transaction((txn) => {
|
|
225
|
+
txn.set('test-2', { id: 'test-2' as ID, typeName: 'test', name: 'Bob', value: 200 })
|
|
226
|
+
throw new Error('oops')
|
|
227
|
+
})
|
|
228
|
+
}).toThrow('oops')
|
|
229
|
+
|
|
230
|
+
// test-2 should not exist
|
|
231
|
+
const result = storage.transaction((txn) => txn.get('test-2'))
|
|
232
|
+
expect(result.result).toBeUndefined()
|
|
233
|
+
|
|
234
|
+
// test-1 should still exist
|
|
235
|
+
const result1 = storage.transaction((txn) => txn.get('test-1'))
|
|
236
|
+
expect(result1.result?.name).toBe('Alice')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('onChange', () => {
|
|
241
|
+
it('notifies on changes', async () => {
|
|
242
|
+
const changes: { id?: string; documentClock: number }[] = []
|
|
243
|
+
storage.onChange((change) => {
|
|
244
|
+
changes.push(change)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
storage.transaction((txn) => {
|
|
248
|
+
txn.set('test-1', { id: 'test-1' as ID, typeName: 'test', name: 'Alice', value: 100 })
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// onChange uses microtask, so wait for it
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
253
|
+
|
|
254
|
+
expect(changes).toHaveLength(1)
|
|
255
|
+
expect(changes[0].documentClock).toBe(1)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('hasBeenInitialized', () => {
|
|
260
|
+
it('returns true for initialized storage', () => {
|
|
261
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('returns false for uninitialized storage', () => {
|
|
265
|
+
const freshDb = new DatabaseSync(':memory:')
|
|
266
|
+
const freshWrapper = new NodeSqliteWrapper(freshDb)
|
|
267
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(freshWrapper)).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { NodeSqliteWrapper } from './NodeSqliteWrapper'
|
|
4
|
+
|
|
5
|
+
describe('NodeSqliteSyncWrapper', () => {
|
|
6
|
+
let db: DatabaseSync
|
|
7
|
+
let wrapper: NodeSqliteWrapper
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
db = new DatabaseSync(':memory:')
|
|
11
|
+
wrapper = new NodeSqliteWrapper(db)
|
|
12
|
+
wrapper.exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('exec', () => {
|
|
16
|
+
it('executes DDL statements', () => {
|
|
17
|
+
wrapper.exec('CREATE TABLE another (id INTEGER PRIMARY KEY)')
|
|
18
|
+
// Verify table exists by inserting
|
|
19
|
+
wrapper.prepare('INSERT INTO another (id) VALUES (?)').run(1)
|
|
20
|
+
const results = wrapper.prepare<{ id: number }>('SELECT * FROM another').all()
|
|
21
|
+
expect(results).toEqual([{ id: 1 }])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('executes multi-statement DDL', () => {
|
|
25
|
+
wrapper.exec(`
|
|
26
|
+
CREATE TABLE t1 (id INTEGER PRIMARY KEY);
|
|
27
|
+
CREATE TABLE t2 (id INTEGER PRIMARY KEY);
|
|
28
|
+
CREATE TABLE t3 (id INTEGER PRIMARY KEY)
|
|
29
|
+
`)
|
|
30
|
+
// Verify all tables exist
|
|
31
|
+
wrapper.prepare('INSERT INTO t1 (id) VALUES (?)').run(1)
|
|
32
|
+
wrapper.prepare('INSERT INTO t2 (id) VALUES (?)').run(2)
|
|
33
|
+
wrapper.prepare('INSERT INTO t3 (id) VALUES (?)').run(3)
|
|
34
|
+
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t1').all()).toEqual([{ id: 1 }])
|
|
35
|
+
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t2').all()).toEqual([{ id: 2 }])
|
|
36
|
+
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t3').all()).toEqual([{ id: 3 }])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('prepare', () => {
|
|
41
|
+
describe('all()', () => {
|
|
42
|
+
it('returns all results as an array', () => {
|
|
43
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
44
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
45
|
+
|
|
46
|
+
const results = wrapper
|
|
47
|
+
.prepare<{ id: number; name: string; value: number }>('SELECT * FROM test ORDER BY id')
|
|
48
|
+
.all()
|
|
49
|
+
|
|
50
|
+
expect(results).toEqual([
|
|
51
|
+
{ id: 1, name: 'alice', value: 100 },
|
|
52
|
+
{ id: 2, name: 'bob', value: 200 },
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('handles queries with bindings', () => {
|
|
57
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
58
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
59
|
+
|
|
60
|
+
const results = wrapper
|
|
61
|
+
.prepare<{ name: string }>('SELECT name FROM test WHERE value > ?')
|
|
62
|
+
.all(150)
|
|
63
|
+
|
|
64
|
+
expect(results).toEqual([{ name: 'bob' }])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns empty array for DML statements', () => {
|
|
68
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
69
|
+
|
|
70
|
+
const stmt = wrapper.prepare('UPDATE test SET value = ? WHERE id = ?')
|
|
71
|
+
expect(stmt.all(999, 1)).toEqual([])
|
|
72
|
+
|
|
73
|
+
// Verify the update happened
|
|
74
|
+
const result = wrapper
|
|
75
|
+
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
|
|
76
|
+
.all(1)
|
|
77
|
+
expect(result).toEqual([{ value: 999 }])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles INSERT with RETURNING clause', () => {
|
|
81
|
+
const results = wrapper
|
|
82
|
+
.prepare<{
|
|
83
|
+
id: number
|
|
84
|
+
name: string
|
|
85
|
+
}>('INSERT INTO test (name, value) VALUES (?, ?) RETURNING id, name')
|
|
86
|
+
.all('charlie', 300)
|
|
87
|
+
|
|
88
|
+
expect(results).toHaveLength(1)
|
|
89
|
+
expect(results[0].name).toBe('charlie')
|
|
90
|
+
expect(typeof results[0].id).toBe('number')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('iterate()', () => {
|
|
95
|
+
it('returns results via iteration', () => {
|
|
96
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
97
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
98
|
+
|
|
99
|
+
const stmt = wrapper.prepare<{ id: number; name: string; value: number }>(
|
|
100
|
+
'SELECT * FROM test ORDER BY id'
|
|
101
|
+
)
|
|
102
|
+
const results: { id: number; name: string; value: number }[] = []
|
|
103
|
+
for (const row of stmt.iterate()) {
|
|
104
|
+
results.push(row)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(results).toEqual([
|
|
108
|
+
{ id: 1, name: 'alice', value: 100 },
|
|
109
|
+
{ id: 2, name: 'bob', value: 200 },
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('handles queries with bindings', () => {
|
|
114
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
115
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
116
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(3, 'carol', 300)
|
|
117
|
+
|
|
118
|
+
const results: { name: string }[] = []
|
|
119
|
+
for (const row of wrapper
|
|
120
|
+
.prepare<{ name: string }>('SELECT name FROM test WHERE value > ?')
|
|
121
|
+
.iterate(150)) {
|
|
122
|
+
results.push(row)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(results).toEqual([{ name: 'bob' }, { name: 'carol' }])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('run()', () => {
|
|
130
|
+
it('executes DML without returning results', () => {
|
|
131
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
132
|
+
|
|
133
|
+
// Verify the insert happened
|
|
134
|
+
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
|
|
135
|
+
expect(results).toEqual([{ id: 1 }])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('executes UPDATE', () => {
|
|
139
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
140
|
+
wrapper.prepare('UPDATE test SET value = ? WHERE id = ?').run(999, 1)
|
|
141
|
+
|
|
142
|
+
const results = wrapper
|
|
143
|
+
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
|
|
144
|
+
.all(1)
|
|
145
|
+
expect(results).toEqual([{ value: 999 }])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('executes DELETE', () => {
|
|
149
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
150
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
151
|
+
wrapper.prepare('DELETE FROM test WHERE id = ?').run(1)
|
|
152
|
+
|
|
153
|
+
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
|
|
154
|
+
expect(results).toEqual([{ id: 2 }])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('prepared statement reuse', () => {
|
|
159
|
+
it('can be reused with different bindings', () => {
|
|
160
|
+
const insert = wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
|
|
161
|
+
insert.run(1, 'alice', 100)
|
|
162
|
+
insert.run(2, 'bob', 200)
|
|
163
|
+
insert.run(3, 'carol', 300)
|
|
164
|
+
|
|
165
|
+
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id').all()
|
|
166
|
+
expect(results).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('iterate can be called multiple times', () => {
|
|
170
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
171
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
172
|
+
|
|
173
|
+
const stmt = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id')
|
|
174
|
+
|
|
175
|
+
// First iteration
|
|
176
|
+
const results1: number[] = []
|
|
177
|
+
for (const row of stmt.iterate()) {
|
|
178
|
+
results1.push(row.id)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Second iteration
|
|
182
|
+
const results2: number[] = []
|
|
183
|
+
for (const row of stmt.iterate()) {
|
|
184
|
+
results2.push(row.id)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
expect(results1).toEqual([1, 2])
|
|
188
|
+
expect(results2).toEqual([1, 2])
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('only executes first statement in multi-statement SQL (node:sqlite limitation)', () => {
|
|
193
|
+
// node:sqlite's prepare() silently ignores statements after the first semicolon
|
|
194
|
+
wrapper.prepare('INSERT INTO test (id) VALUES (1); INSERT INTO test (id) VALUES (2)').run()
|
|
195
|
+
|
|
196
|
+
// Only the first statement was executed
|
|
197
|
+
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
|
|
198
|
+
expect(results).toEqual([{ id: 1 }])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('handles string with semicolon in value (not multi-statement)', () => {
|
|
202
|
+
wrapper
|
|
203
|
+
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
|
|
204
|
+
.run(1, 'hello; world', 100)
|
|
205
|
+
|
|
206
|
+
const results = wrapper.prepare<{ name: string }>('SELECT name FROM test').all()
|
|
207
|
+
expect(results).toEqual([{ name: 'hello; world' }])
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('transaction', () => {
|
|
212
|
+
it('commits on success', () => {
|
|
213
|
+
wrapper.transaction(() => {
|
|
214
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
215
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id').all()
|
|
219
|
+
expect(results).toEqual([{ id: 1 }, { id: 2 }])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('rolls back on error', () => {
|
|
223
|
+
expect(() => {
|
|
224
|
+
wrapper.transaction(() => {
|
|
225
|
+
wrapper
|
|
226
|
+
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
|
|
227
|
+
.run(1, 'alice', 100)
|
|
228
|
+
throw new Error('oops')
|
|
229
|
+
})
|
|
230
|
+
}).toThrow('oops')
|
|
231
|
+
|
|
232
|
+
const results = wrapper.prepare('SELECT * FROM test').all()
|
|
233
|
+
expect(results).toEqual([])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns the callback result', () => {
|
|
237
|
+
const result = wrapper.transaction(() => {
|
|
238
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
239
|
+
return 'done'
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
expect(result).toBe('done')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('supports nested reads within transaction', () => {
|
|
246
|
+
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
|
|
247
|
+
|
|
248
|
+
const result = wrapper.transaction(() => {
|
|
249
|
+
const rows = wrapper
|
|
250
|
+
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
|
|
251
|
+
.all(1)
|
|
252
|
+
wrapper.prepare('UPDATE test SET value = ? WHERE id = ?').run(rows[0].value + 50, 1)
|
|
253
|
+
return wrapper.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?').all(1)[0]
|
|
254
|
+
.value
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
expect(result).toBe(150)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('re-throws the original error after rollback', () => {
|
|
261
|
+
const customError = new Error('custom error')
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
wrapper.transaction(() => {
|
|
265
|
+
throw customError
|
|
266
|
+
})
|
|
267
|
+
} catch (e) {
|
|
268
|
+
expect(e).toBe(customError)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type TLSqliteInputValue,
|
|
3
|
+
type TLSqliteRow,
|
|
4
|
+
type TLSyncSqliteStatement,
|
|
5
|
+
type TLSyncSqliteWrapper,
|
|
6
|
+
type TLSyncSqliteWrapperConfig,
|
|
7
|
+
} from './SQLiteSyncStorage'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal interface for a synchronous SQLite database.
|
|
11
|
+
*
|
|
12
|
+
* This interface is compatible with:
|
|
13
|
+
* - `node:sqlite` DatabaseSync (Node.js 22.5+)
|
|
14
|
+
* - `better-sqlite3` Database
|
|
15
|
+
*
|
|
16
|
+
* Any SQLite library that provides synchronous `exec` and `prepare` methods
|
|
17
|
+
* with the signatures below can be used with {@link NodeSqliteWrapper}.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
export interface SyncSqliteDatabase {
|
|
22
|
+
/** Execute raw SQL without returning results */
|
|
23
|
+
exec(sql: string): void
|
|
24
|
+
/** Prepare a statement for execution */
|
|
25
|
+
prepare(sql: string): {
|
|
26
|
+
iterate(...params: unknown[]): IterableIterator<unknown>
|
|
27
|
+
all(...params: unknown[]): unknown[]
|
|
28
|
+
run(...params: unknown[]): unknown
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A wrapper around synchronous SQLite databases that implements TLSyncSqliteWrapper.
|
|
34
|
+
* Works with both `node:sqlite` DatabaseSync (Node.js 22.5+) and `better-sqlite3` Database.
|
|
35
|
+
*
|
|
36
|
+
* Use this wrapper with SQLiteSyncStorage to persist tldraw sync state to a SQLite database
|
|
37
|
+
* in Node.js environments.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* // With node:sqlite (Node.js 22.5+)
|
|
42
|
+
* import { DatabaseSync } from 'node:sqlite'
|
|
43
|
+
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
|
|
44
|
+
*
|
|
45
|
+
* const db = new DatabaseSync(':memory:')
|
|
46
|
+
* const sql = new NodeSqliteWrapper(db)
|
|
47
|
+
* const storage = new SQLiteSyncStorage({ sql })
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* // With better-sqlite3
|
|
53
|
+
* import Database from 'better-sqlite3'
|
|
54
|
+
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
|
|
55
|
+
*
|
|
56
|
+
* const db = new Database(':memory:')
|
|
57
|
+
* const sql = new NodeSqliteWrapper(db)
|
|
58
|
+
* const storage = new SQLiteSyncStorage({ sql })
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* // With table prefix to avoid conflicts with other tables
|
|
64
|
+
* const sql = new NodeSqliteWrapper(db, { tablePrefix: 'tldraw_' })
|
|
65
|
+
* // Creates tables: tldraw_documents, tldraw_tombstones, tldraw_metadata
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
export class NodeSqliteWrapper implements TLSyncSqliteWrapper {
|
|
71
|
+
constructor(
|
|
72
|
+
private db: SyncSqliteDatabase,
|
|
73
|
+
public config?: TLSyncSqliteWrapperConfig
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
exec(sql: string): void {
|
|
77
|
+
this.db.exec(sql)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
prepare<
|
|
81
|
+
TResult extends TLSqliteRow | void = void,
|
|
82
|
+
TParams extends TLSqliteInputValue[] = TLSqliteInputValue[],
|
|
83
|
+
>(sql: string): TLSyncSqliteStatement<TResult, TParams> {
|
|
84
|
+
return this.db.prepare(sql) as unknown as TLSyncSqliteStatement<TResult, TParams>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
transaction<T>(callback: () => T): T {
|
|
88
|
+
this.db.exec('BEGIN')
|
|
89
|
+
let result: T
|
|
90
|
+
try {
|
|
91
|
+
result = callback()
|
|
92
|
+
} catch (e) {
|
|
93
|
+
this.db.exec('ROLLBACK')
|
|
94
|
+
throw e
|
|
95
|
+
}
|
|
96
|
+
this.db.exec('COMMIT')
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -68,6 +68,7 @@ describe('RoomSession state transitions', () => {
|
|
|
68
68
|
isReadonly: initialSession.isReadonly,
|
|
69
69
|
requiresLegacyRejection: initialSession.requiresLegacyRejection,
|
|
70
70
|
serializedSchema: mockSerializedSchema,
|
|
71
|
+
requiresDownMigrations: false,
|
|
71
72
|
supportsStringAppend: true,
|
|
72
73
|
lastInteractionTime: Date.now(),
|
|
73
74
|
debounceTimer: null,
|
package/src/lib/RoomSession.ts
CHANGED
|
@@ -132,6 +132,8 @@ export type RoomSession<R extends UnknownRecord, Meta> =
|
|
|
132
132
|
state: typeof RoomSessionState.Connected
|
|
133
133
|
/** Serialized schema information for this connected session */
|
|
134
134
|
serializedSchema: SerializedSchema
|
|
135
|
+
/** Whether this session requires down migrations */
|
|
136
|
+
requiresDownMigrations: boolean
|
|
135
137
|
/** Timestamp of the last interaction or message from this session */
|
|
136
138
|
lastInteractionTime: number
|
|
137
139
|
/** Timer for debouncing operations, if active */
|