@tldraw/sync-core 4.2.2 → 4.2.3

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 (84) hide show
  1. package/dist-cjs/index.d.ts +58 -483
  2. package/dist-cjs/index.js +3 -13
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/RoomSession.js.map +1 -1
  5. package/dist-cjs/lib/TLSocketRoom.js +69 -117
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +0 -7
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +688 -357
  10. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  11. package/dist-cjs/lib/chunk.js +2 -2
  12. package/dist-cjs/lib/chunk.js.map +1 -1
  13. package/dist-esm/index.d.mts +58 -483
  14. package/dist-esm/index.mjs +5 -20
  15. package/dist-esm/index.mjs.map +2 -2
  16. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  17. package/dist-esm/lib/TLSocketRoom.mjs +70 -121
  18. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncClient.mjs +0 -7
  20. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncRoom.mjs +702 -370
  22. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  23. package/dist-esm/lib/chunk.mjs +2 -2
  24. package/dist-esm/lib/chunk.mjs.map +1 -1
  25. package/package.json +11 -12
  26. package/src/index.ts +3 -32
  27. package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
  28. package/src/lib/RoomSession.test.ts +0 -1
  29. package/src/lib/RoomSession.ts +0 -2
  30. package/src/lib/TLSocketRoom.ts +114 -228
  31. package/src/lib/TLSyncClient.ts +0 -12
  32. package/src/lib/TLSyncRoom.ts +913 -473
  33. package/src/lib/chunk.ts +2 -2
  34. package/src/test/FuzzEditor.ts +5 -4
  35. package/src/test/TLSocketRoom.test.ts +49 -255
  36. package/src/test/TLSyncRoom.test.ts +534 -1024
  37. package/src/test/TestServer.ts +1 -12
  38. package/src/test/customMessages.test.ts +1 -1
  39. package/src/test/presenceMode.test.ts +6 -6
  40. package/src/test/pruneTombstones.test.ts +178 -0
  41. package/src/test/syncFuzz.test.ts +4 -2
  42. package/src/test/upgradeDowngrade.test.ts +8 -290
  43. package/src/test/validation.test.ts +10 -15
  44. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
  45. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
  46. package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
  47. package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
  48. package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
  49. package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
  50. package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
  51. package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
  52. package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
  53. package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
  54. package/dist-cjs/lib/TLSyncStorage.js +0 -76
  55. package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
  56. package/dist-cjs/lib/recordDiff.js +0 -52
  57. package/dist-cjs/lib/recordDiff.js.map +0 -7
  58. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
  59. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
  60. package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
  61. package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
  62. package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
  63. package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
  64. package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
  65. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
  66. package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
  67. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
  68. package/dist-esm/lib/TLSyncStorage.mjs +0 -56
  69. package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
  70. package/dist-esm/lib/recordDiff.mjs +0 -32
  71. package/dist-esm/lib/recordDiff.mjs.map +0 -7
  72. package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
  73. package/src/lib/InMemorySyncStorage.ts +0 -387
  74. package/src/lib/MicrotaskNotifier.test.ts +0 -429
  75. package/src/lib/MicrotaskNotifier.ts +0 -38
  76. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  77. package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
  78. package/src/lib/NodeSqliteWrapper.ts +0 -99
  79. package/src/lib/SQLiteSyncStorage.ts +0 -627
  80. package/src/lib/TLSyncStorage.ts +0 -216
  81. package/src/lib/computeTombstonePruning.test.ts +0 -352
  82. package/src/lib/recordDiff.ts +0 -73
  83. package/src/test/InMemorySyncStorage.test.ts +0 -1684
  84. package/src/test/SQLiteSyncStorage.test.ts +0 -1378
@@ -1,270 +0,0 @@
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
- })
@@ -1,272 +0,0 @@
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
- })
@@ -1,99 +0,0 @@
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
- }