@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.
- package/dist-cjs/index.d.ts +58 -483
- package/dist-cjs/index.js +3 -13
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +69 -117
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +0 -7
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +688 -357
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-esm/index.d.mts +58 -483
- package/dist-esm/index.mjs +5 -20
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +70 -121
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +0 -7
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +702 -370
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/package.json +11 -12
- package/src/index.ts +3 -32
- package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
- package/src/lib/RoomSession.test.ts +0 -1
- package/src/lib/RoomSession.ts +0 -2
- package/src/lib/TLSocketRoom.ts +114 -228
- package/src/lib/TLSyncClient.ts +0 -12
- package/src/lib/TLSyncRoom.ts +913 -473
- package/src/lib/chunk.ts +2 -2
- package/src/test/FuzzEditor.ts +5 -4
- package/src/test/TLSocketRoom.test.ts +49 -255
- package/src/test/TLSyncRoom.test.ts +534 -1024
- package/src/test/TestServer.ts +1 -12
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/pruneTombstones.test.ts +178 -0
- package/src/test/syncFuzz.test.ts +4 -2
- package/src/test/upgradeDowngrade.test.ts +8 -290
- package/src/test/validation.test.ts +10 -15
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
- package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
- package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
- package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
- package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
- package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
- package/dist-cjs/lib/TLSyncStorage.js +0 -76
- package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
- package/dist-cjs/lib/recordDiff.js +0 -52
- package/dist-cjs/lib/recordDiff.js.map +0 -7
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
- package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
- package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
- package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/TLSyncStorage.mjs +0 -56
- package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/recordDiff.mjs +0 -32
- package/dist-esm/lib/recordDiff.mjs.map +0 -7
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
- package/src/lib/InMemorySyncStorage.ts +0 -387
- package/src/lib/MicrotaskNotifier.test.ts +0 -429
- package/src/lib/MicrotaskNotifier.ts +0 -38
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
- package/src/lib/NodeSqliteWrapper.ts +0 -99
- package/src/lib/SQLiteSyncStorage.ts +0 -627
- package/src/lib/TLSyncStorage.ts +0 -216
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/recordDiff.ts +0 -73
- package/src/test/InMemorySyncStorage.test.ts +0 -1684
- package/src/test/SQLiteSyncStorage.test.ts +0 -1378
|
@@ -1,627 +0,0 @@
|
|
|
1
|
-
import { transaction } from '@tldraw/state'
|
|
2
|
-
import { SerializedSchema, StoreSnapshot, UnknownRecord } from '@tldraw/store'
|
|
3
|
-
import { assert, objectMapEntries, throttle } from '@tldraw/utils'
|
|
4
|
-
import {
|
|
5
|
-
computeTombstonePruning,
|
|
6
|
-
DEFAULT_INITIAL_SNAPSHOT,
|
|
7
|
-
MAX_TOMBSTONES,
|
|
8
|
-
} from './InMemorySyncStorage'
|
|
9
|
-
import { MicrotaskNotifier } from './MicrotaskNotifier'
|
|
10
|
-
import { RoomSnapshot } from './TLSyncRoom'
|
|
11
|
-
import {
|
|
12
|
-
convertStoreSnapshotToRoomSnapshot,
|
|
13
|
-
TLSyncForwardDiff,
|
|
14
|
-
TLSyncStorage,
|
|
15
|
-
TLSyncStorageGetChangesSinceResult,
|
|
16
|
-
TLSyncStorageOnChangeCallbackProps,
|
|
17
|
-
TLSyncStorageTransaction,
|
|
18
|
-
TLSyncStorageTransactionCallback,
|
|
19
|
-
TLSyncStorageTransactionOptions,
|
|
20
|
-
TLSyncStorageTransactionResult,
|
|
21
|
-
} from './TLSyncStorage'
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Valid input value types for SQLite query parameters.
|
|
25
|
-
* These are the types that can be passed as bindings to prepared statements.
|
|
26
|
-
* @public
|
|
27
|
-
*/
|
|
28
|
-
export type TLSqliteInputValue = null | number | bigint | string | Uint8Array
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Possible output value types returned from SQLite queries.
|
|
32
|
-
* Includes all input types plus Uint8Array for BLOB columns.
|
|
33
|
-
* @public
|
|
34
|
-
*/
|
|
35
|
-
export type TLSqliteOutputValue = null | number | bigint | string | Uint8Array
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A row returned from a SQLite query, mapping column names to their values.
|
|
39
|
-
* @public
|
|
40
|
-
*/
|
|
41
|
-
export type TLSqliteRow = Record<string, TLSqliteOutputValue>
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A prepared statement that can be executed multiple times with different bindings.
|
|
45
|
-
* @public
|
|
46
|
-
*/
|
|
47
|
-
export interface TLSyncSqliteStatement<
|
|
48
|
-
TResult extends TLSqliteRow | void,
|
|
49
|
-
TParams extends TLSqliteInputValue[] = [],
|
|
50
|
-
> {
|
|
51
|
-
/** Execute the statement and iterate over results one at a time */
|
|
52
|
-
iterate(...bindings: TParams): IterableIterator<TResult>
|
|
53
|
-
/** Execute the statement and return all results as an array */
|
|
54
|
-
all(...bindings: TParams): TResult[]
|
|
55
|
-
/** Execute the statement without returning results (for DML) */
|
|
56
|
-
run(...bindings: TParams): void
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Configuration for SQLiteSyncStorage.
|
|
61
|
-
* @public
|
|
62
|
-
*/
|
|
63
|
-
export interface TLSyncSqliteWrapperConfig {
|
|
64
|
-
/** Prefix for all table names (default: ''). E.g. 'sync_' creates tables 'sync_documents', 'sync_tombstones', 'sync_metadata' */
|
|
65
|
-
tablePrefix?: string
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Interface for SQLite storage with prepare, exec and transaction capabilities.
|
|
70
|
-
* @public
|
|
71
|
-
*/
|
|
72
|
-
export interface TLSyncSqliteWrapper {
|
|
73
|
-
/** Optional configuration for table names. If not provided, defaults are used. */
|
|
74
|
-
readonly config?: TLSyncSqliteWrapperConfig
|
|
75
|
-
/** Prepare a SQL statement for execution */
|
|
76
|
-
prepare<TResult extends TLSqliteRow | void, TParams extends TLSqliteInputValue[] = []>(
|
|
77
|
-
sql: string
|
|
78
|
-
): TLSyncSqliteStatement<TResult, TParams>
|
|
79
|
-
/** Execute raw SQL (for DDL, multi-statement scripts) */
|
|
80
|
-
exec(sql: string): void
|
|
81
|
-
/** Execute a callback within a transaction */
|
|
82
|
-
transaction<T>(callback: () => T): T
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function migrateSqliteSyncStorage(
|
|
86
|
-
storage: TLSyncSqliteWrapper,
|
|
87
|
-
{
|
|
88
|
-
documentsTable = 'documents',
|
|
89
|
-
tombstonesTable = 'tombstones',
|
|
90
|
-
metadataTable = 'metadata',
|
|
91
|
-
}: { documentsTable?: string; tombstonesTable?: string; metadataTable?: string } = {}
|
|
92
|
-
): void {
|
|
93
|
-
let migrationVersion = 0
|
|
94
|
-
try {
|
|
95
|
-
const row = storage
|
|
96
|
-
.prepare<{
|
|
97
|
-
migrationVersion: number
|
|
98
|
-
}>(`SELECT migrationVersion FROM ${metadataTable} LIMIT 1`)
|
|
99
|
-
.all()[0]
|
|
100
|
-
migrationVersion = row?.migrationVersion ?? 0
|
|
101
|
-
} catch (_e) {
|
|
102
|
-
// noop
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (migrationVersion === 0) {
|
|
106
|
-
migrationVersion++
|
|
107
|
-
storage.exec(`
|
|
108
|
-
CREATE TABLE ${documentsTable} (
|
|
109
|
-
id TEXT PRIMARY KEY,
|
|
110
|
-
state BLOB NOT NULL,
|
|
111
|
-
lastChangedClock INTEGER NOT NULL
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
CREATE INDEX idx_${documentsTable}_lastChangedClock ON ${documentsTable}(lastChangedClock);
|
|
115
|
-
|
|
116
|
-
CREATE TABLE ${tombstonesTable} (
|
|
117
|
-
id TEXT PRIMARY KEY,
|
|
118
|
-
clock INTEGER NOT NULL
|
|
119
|
-
);
|
|
120
|
-
CREATE INDEX idx_${tombstonesTable}_clock ON ${tombstonesTable}(clock);
|
|
121
|
-
|
|
122
|
-
-- This table is used to store the metadata for the sync storage.
|
|
123
|
-
-- There should only be one row in this table.
|
|
124
|
-
CREATE TABLE ${metadataTable} (
|
|
125
|
-
migrationVersion INTEGER NOT NULL,
|
|
126
|
-
documentClock INTEGER NOT NULL,
|
|
127
|
-
tombstoneHistoryStartsAtClock INTEGER NOT NULL,
|
|
128
|
-
schema TEXT NOT NULL
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
INSERT INTO ${metadataTable} (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema) VALUES (2, 0, 0, '')
|
|
132
|
-
`)
|
|
133
|
-
// Skip migration 2 since we created the table with BLOB already
|
|
134
|
-
migrationVersion++
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (migrationVersion === 1) {
|
|
138
|
-
// Migration 2: Convert state column from TEXT to BLOB
|
|
139
|
-
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
|
140
|
-
migrationVersion++
|
|
141
|
-
storage.exec(`
|
|
142
|
-
CREATE TABLE ${documentsTable}_new (
|
|
143
|
-
id TEXT PRIMARY KEY,
|
|
144
|
-
state BLOB NOT NULL,
|
|
145
|
-
lastChangedClock INTEGER NOT NULL
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
INSERT INTO ${documentsTable}_new (id, state, lastChangedClock)
|
|
149
|
-
SELECT id, CAST(state AS BLOB), lastChangedClock FROM ${documentsTable};
|
|
150
|
-
|
|
151
|
-
DROP TABLE ${documentsTable};
|
|
152
|
-
|
|
153
|
-
ALTER TABLE ${documentsTable}_new RENAME TO ${documentsTable};
|
|
154
|
-
|
|
155
|
-
CREATE INDEX idx_${documentsTable}_lastChangedClock ON ${documentsTable}(lastChangedClock);
|
|
156
|
-
`)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// add more migrations here if and when needed
|
|
160
|
-
|
|
161
|
-
storage.exec(`UPDATE ${metadataTable} SET migrationVersion = ${migrationVersion}`)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const textEncoder = new TextEncoder()
|
|
165
|
-
const textDecoder = new TextDecoder()
|
|
166
|
-
|
|
167
|
-
function encodeState(state: unknown): Uint8Array {
|
|
168
|
-
return textEncoder.encode(JSON.stringify(state))
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function decodeState<T>(state: Uint8Array): T {
|
|
172
|
-
return JSON.parse(textDecoder.decode(state))
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* SQLite-based implementation of TLSyncStorage.
|
|
177
|
-
* Stores documents, tombstones, metadata, and clock values in SQLite tables.
|
|
178
|
-
*
|
|
179
|
-
* This storage backend provides persistent synchronization state that survives
|
|
180
|
-
* process restarts, unlike InMemorySyncStorage which loses data when the process ends.
|
|
181
|
-
*
|
|
182
|
-
* @example
|
|
183
|
-
* ```ts
|
|
184
|
-
* // With Cloudflare Durable Objects
|
|
185
|
-
* import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper } from '@tldraw/sync-core'
|
|
186
|
-
*
|
|
187
|
-
* const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
|
188
|
-
* const storage = new SQLiteSyncStorage({ sql })
|
|
189
|
-
* ```
|
|
190
|
-
*
|
|
191
|
-
* @example
|
|
192
|
-
* ```ts
|
|
193
|
-
* // With Node.js sqlite (Node 22.5+)
|
|
194
|
-
* import { DatabaseSync } from 'node:sqlite'
|
|
195
|
-
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
|
|
196
|
-
*
|
|
197
|
-
* const db = new DatabaseSync('sync-state.db')
|
|
198
|
-
* const sql = new NodeSqliteWrapper(db)
|
|
199
|
-
* const storage = new SQLiteSyncStorage({ sql })
|
|
200
|
-
* ```
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```ts
|
|
204
|
-
* // Initialize with an existing snapshot
|
|
205
|
-
* const storage = new SQLiteSyncStorage({ sql, snapshot: existingSnapshot })
|
|
206
|
-
* ```
|
|
207
|
-
*
|
|
208
|
-
* @public
|
|
209
|
-
*/
|
|
210
|
-
export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
|
|
211
|
-
/**
|
|
212
|
-
* Check if the storage has been initialized (has data in the clock table).
|
|
213
|
-
* Useful for determining whether to load from an external source on first access.
|
|
214
|
-
*/
|
|
215
|
-
static hasBeenInitialized(storage: TLSyncSqliteWrapper): boolean {
|
|
216
|
-
const prefix = storage.config?.tablePrefix ?? ''
|
|
217
|
-
try {
|
|
218
|
-
const schema = storage
|
|
219
|
-
.prepare<{ schema: string }>(`SELECT schema FROM ${prefix}metadata LIMIT 1`)
|
|
220
|
-
.all()[0]?.schema
|
|
221
|
-
return !!schema
|
|
222
|
-
} catch (_e) {
|
|
223
|
-
return false
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Get the current document clock value from storage without fully initializing.
|
|
229
|
-
* Returns null if storage has not been initialized.
|
|
230
|
-
* Useful for comparing storage freshness against external sources.
|
|
231
|
-
*/
|
|
232
|
-
static getDocumentClock(storage: TLSyncSqliteWrapper): number | null {
|
|
233
|
-
const prefix = storage.config?.tablePrefix ?? ''
|
|
234
|
-
try {
|
|
235
|
-
const row = storage
|
|
236
|
-
.prepare<{ documentClock: number }>(`SELECT documentClock FROM ${prefix}metadata LIMIT 1`)
|
|
237
|
-
.all()[0]
|
|
238
|
-
// documentClock exists but could be 0, so we check if the storage is initialized
|
|
239
|
-
if (row && SQLiteSyncStorage.hasBeenInitialized(storage)) {
|
|
240
|
-
return row.documentClock
|
|
241
|
-
}
|
|
242
|
-
return null
|
|
243
|
-
} catch (_e) {
|
|
244
|
-
return null
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Prepared statements - created once, reused many times
|
|
249
|
-
private readonly stmts
|
|
250
|
-
|
|
251
|
-
private readonly sql: TLSyncSqliteWrapper
|
|
252
|
-
|
|
253
|
-
constructor({
|
|
254
|
-
sql,
|
|
255
|
-
snapshot,
|
|
256
|
-
onChange,
|
|
257
|
-
}: {
|
|
258
|
-
sql: TLSyncSqliteWrapper
|
|
259
|
-
snapshot?: RoomSnapshot | StoreSnapshot<R>
|
|
260
|
-
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
|
|
261
|
-
}) {
|
|
262
|
-
this.sql = sql
|
|
263
|
-
const prefix = sql.config?.tablePrefix ?? ''
|
|
264
|
-
const documentsTable = `${prefix}documents`
|
|
265
|
-
const tombstonesTable = `${prefix}tombstones`
|
|
266
|
-
const metadataTable = `${prefix}metadata`
|
|
267
|
-
|
|
268
|
-
migrateSqliteSyncStorage(this.sql, { documentsTable, tombstonesTable, metadataTable })
|
|
269
|
-
|
|
270
|
-
// Prepare all statements once
|
|
271
|
-
this.stmts = {
|
|
272
|
-
// Metadata
|
|
273
|
-
getDocumentClock: this.sql.prepare<{ documentClock: number }>(
|
|
274
|
-
`SELECT documentClock FROM ${metadataTable} LIMIT 1`
|
|
275
|
-
),
|
|
276
|
-
getTombstoneHistoryStartsAtClock: this.sql.prepare<{ tombstoneHistoryStartsAtClock: number }>(
|
|
277
|
-
`SELECT tombstoneHistoryStartsAtClock FROM ${metadataTable}`
|
|
278
|
-
),
|
|
279
|
-
getSchema: this.sql.prepare<{ schema: string }>(`SELECT schema FROM ${metadataTable}`),
|
|
280
|
-
setSchema: this.sql.prepare<void, [schema: string]>(`UPDATE ${metadataTable} SET schema = ?`),
|
|
281
|
-
setTombstoneHistoryStartsAtClock: this.sql.prepare<void, [clock: number]>(
|
|
282
|
-
`UPDATE ${metadataTable} SET tombstoneHistoryStartsAtClock = ?`
|
|
283
|
-
),
|
|
284
|
-
incrementDocumentClock: this.sql.prepare<void>(
|
|
285
|
-
`UPDATE ${metadataTable} SET documentClock = documentClock + 1`
|
|
286
|
-
),
|
|
287
|
-
|
|
288
|
-
// Documents
|
|
289
|
-
getDocument: this.sql.prepare<{ state: Uint8Array }, [id: string]>(
|
|
290
|
-
`SELECT state FROM ${documentsTable} WHERE id = ?`
|
|
291
|
-
),
|
|
292
|
-
insertDocument: this.sql.prepare<
|
|
293
|
-
void,
|
|
294
|
-
[id: string, state: Uint8Array, lastChangedClock: number]
|
|
295
|
-
>(`INSERT OR REPLACE INTO ${documentsTable} (id, state, lastChangedClock) VALUES (?, ?, ?)`),
|
|
296
|
-
deleteDocument: this.sql.prepare<void, [id: string]>(
|
|
297
|
-
`DELETE FROM ${documentsTable} WHERE id = ?`
|
|
298
|
-
),
|
|
299
|
-
documentExists: this.sql.prepare<{ id: string }, [id: string]>(
|
|
300
|
-
`SELECT id FROM ${documentsTable} WHERE id = ?`
|
|
301
|
-
),
|
|
302
|
-
iterateDocuments: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }>(
|
|
303
|
-
`SELECT state, lastChangedClock FROM ${documentsTable}`
|
|
304
|
-
),
|
|
305
|
-
iterateDocumentEntries: this.sql.prepare<{ id: string; state: Uint8Array }>(
|
|
306
|
-
`SELECT id, state FROM ${documentsTable}`
|
|
307
|
-
),
|
|
308
|
-
iterateDocumentKeys: this.sql.prepare<{ id: string }>(`SELECT id FROM ${documentsTable}`),
|
|
309
|
-
iterateDocumentValues: this.sql.prepare<{ state: Uint8Array }>(
|
|
310
|
-
`SELECT state FROM ${documentsTable}`
|
|
311
|
-
),
|
|
312
|
-
getDocumentsChangedSince: this.sql.prepare<{ state: Uint8Array }, [sinceClock: number]>(
|
|
313
|
-
`SELECT state FROM ${documentsTable} WHERE lastChangedClock > ?`
|
|
314
|
-
),
|
|
315
|
-
|
|
316
|
-
// Tombstones
|
|
317
|
-
insertTombstone: this.sql.prepare<void, [id: string, clock: number]>(
|
|
318
|
-
`INSERT OR REPLACE INTO ${tombstonesTable} (id, clock) VALUES (?, ?)`
|
|
319
|
-
),
|
|
320
|
-
deleteTombstone: this.sql.prepare<void, [id: string]>(
|
|
321
|
-
`DELETE FROM ${tombstonesTable} WHERE id = ?`
|
|
322
|
-
),
|
|
323
|
-
deleteTombstonesBefore: this.sql.prepare<void, [clock: number]>(
|
|
324
|
-
`DELETE FROM ${tombstonesTable} WHERE clock < ?`
|
|
325
|
-
),
|
|
326
|
-
countTombstones: this.sql.prepare<{ count: number }>(
|
|
327
|
-
`SELECT count(*) as count FROM ${tombstonesTable}`
|
|
328
|
-
),
|
|
329
|
-
iterateTombstones: this.sql.prepare<{ id: string; clock: number }>(
|
|
330
|
-
`SELECT id, clock FROM ${tombstonesTable} ORDER BY clock ASC`
|
|
331
|
-
),
|
|
332
|
-
getTombstonesChangedSince: this.sql.prepare<{ id: string }, [sinceClock: number]>(
|
|
333
|
-
`SELECT id FROM ${tombstonesTable} WHERE clock > ?`
|
|
334
|
-
),
|
|
335
|
-
|
|
336
|
-
// Initial setup (only used when loading a snapshot)
|
|
337
|
-
updateMetadata: this.sql.prepare<
|
|
338
|
-
void,
|
|
339
|
-
[documentClock: number, tombstoneHistoryStartsAtClock: number, schema: string]
|
|
340
|
-
>(
|
|
341
|
-
`UPDATE ${metadataTable} SET documentClock = ?, tombstoneHistoryStartsAtClock = ?, schema = ?`
|
|
342
|
-
),
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Check if we already have data
|
|
346
|
-
const hasData = SQLiteSyncStorage.hasBeenInitialized(sql)
|
|
347
|
-
|
|
348
|
-
if (snapshot || !hasData) {
|
|
349
|
-
snapshot = convertStoreSnapshotToRoomSnapshot(snapshot ?? DEFAULT_INITIAL_SNAPSHOT)
|
|
350
|
-
|
|
351
|
-
const documentClock = snapshot.documentClock ?? snapshot.clock ?? 0
|
|
352
|
-
const tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? documentClock
|
|
353
|
-
|
|
354
|
-
// Clear existing data
|
|
355
|
-
this.sql.exec(`
|
|
356
|
-
DELETE FROM ${documentsTable};
|
|
357
|
-
DELETE FROM ${tombstonesTable};
|
|
358
|
-
`)
|
|
359
|
-
|
|
360
|
-
// Insert documents
|
|
361
|
-
for (const doc of snapshot.documents) {
|
|
362
|
-
this.stmts.insertDocument.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Insert tombstones
|
|
366
|
-
if (snapshot.tombstones) {
|
|
367
|
-
for (const [id, clock] of objectMapEntries(snapshot.tombstones)) {
|
|
368
|
-
this.stmts.insertTombstone.run(id, clock)
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Insert metadata row
|
|
373
|
-
this.stmts.updateMetadata.run(
|
|
374
|
-
documentClock,
|
|
375
|
-
tombstoneHistoryStartsAtClock,
|
|
376
|
-
JSON.stringify(snapshot.schema)
|
|
377
|
-
)
|
|
378
|
-
}
|
|
379
|
-
if (onChange) {
|
|
380
|
-
this.onChange(onChange)
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
private notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()
|
|
385
|
-
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => void): () => void {
|
|
386
|
-
return this.notifier.register(callback)
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
transaction<T>(
|
|
390
|
-
callback: TLSyncStorageTransactionCallback<R, T>,
|
|
391
|
-
opts?: TLSyncStorageTransactionOptions
|
|
392
|
-
): TLSyncStorageTransactionResult<T, R> {
|
|
393
|
-
const clockBefore = this.getClock()
|
|
394
|
-
const trackChanges = opts?.emitChanges === 'always'
|
|
395
|
-
return this.sql.transaction(() => {
|
|
396
|
-
const txn = new SQLiteSyncStorageTransaction<R>(this, this.stmts)
|
|
397
|
-
let result: T
|
|
398
|
-
let changes: TLSyncForwardDiff<R> | undefined
|
|
399
|
-
try {
|
|
400
|
-
result = transaction(() => {
|
|
401
|
-
return callback(txn)
|
|
402
|
-
}) as T
|
|
403
|
-
if (trackChanges) {
|
|
404
|
-
changes = txn.getChangesSince(clockBefore)?.diff
|
|
405
|
-
}
|
|
406
|
-
} finally {
|
|
407
|
-
txn.close()
|
|
408
|
-
}
|
|
409
|
-
if (
|
|
410
|
-
typeof result === 'object' &&
|
|
411
|
-
result &&
|
|
412
|
-
'then' in result &&
|
|
413
|
-
typeof result.then === 'function'
|
|
414
|
-
) {
|
|
415
|
-
throw new Error('Transaction must return a value, not a promise')
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const clockAfter = this.getClock()
|
|
419
|
-
const didChange = clockAfter > clockBefore
|
|
420
|
-
if (didChange) {
|
|
421
|
-
this.notifier.notify({ id: opts?.id, documentClock: clockAfter })
|
|
422
|
-
}
|
|
423
|
-
return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
|
|
424
|
-
})
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
getClock(): number {
|
|
428
|
-
const clockRow = this.stmts.getDocumentClock.all()[0]
|
|
429
|
-
return clockRow?.documentClock ?? 0
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** @internal */
|
|
433
|
-
_getTombstoneHistoryStartsAtClock(): number {
|
|
434
|
-
const clockRow = this.stmts.getTombstoneHistoryStartsAtClock.all()[0]
|
|
435
|
-
return clockRow?.tombstoneHistoryStartsAtClock ?? 0
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** @internal */
|
|
439
|
-
_getSchema(): SerializedSchema {
|
|
440
|
-
const clockRow = this.stmts.getSchema.all()[0]
|
|
441
|
-
assert(clockRow, 'Storage not initialized - clock row missing')
|
|
442
|
-
return JSON.parse(clockRow.schema)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** @internal */
|
|
446
|
-
_setSchema(schema: SerializedSchema): void {
|
|
447
|
-
this.stmts.setSchema.run(JSON.stringify(schema))
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** @internal */
|
|
451
|
-
pruneTombstones = throttle(
|
|
452
|
-
() => {
|
|
453
|
-
const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
|
|
454
|
-
if (tombstoneCount > MAX_TOMBSTONES) {
|
|
455
|
-
// Get all tombstones sorted by clock ascending (oldest first)
|
|
456
|
-
const tombstones = this.stmts.iterateTombstones.all()
|
|
457
|
-
|
|
458
|
-
const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
|
|
459
|
-
if (result) {
|
|
460
|
-
this.stmts.setTombstoneHistoryStartsAtClock.run(result.newTombstoneHistoryStartsAtClock)
|
|
461
|
-
// Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
|
|
462
|
-
// This works because computeTombstonePruning ensures we never split a clock value.
|
|
463
|
-
this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
},
|
|
467
|
-
1000,
|
|
468
|
-
// prevent this from running synchronously to avoid blocking requests
|
|
469
|
-
{ leading: false }
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
getSnapshot(): RoomSnapshot {
|
|
473
|
-
return {
|
|
474
|
-
tombstoneHistoryStartsAtClock: this._getTombstoneHistoryStartsAtClock(),
|
|
475
|
-
documentClock: this.getClock(),
|
|
476
|
-
documents: Array.from(this._iterateDocuments()),
|
|
477
|
-
tombstones: Object.fromEntries(this._iterateTombstones()),
|
|
478
|
-
schema: this._getSchema(),
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
private *_iterateDocuments(): IterableIterator<{ state: R; lastChangedClock: number }> {
|
|
482
|
-
for (const row of this.stmts.iterateDocuments.iterate()) {
|
|
483
|
-
yield { state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock }
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
private *_iterateTombstones(): IterableIterator<[string, number]> {
|
|
488
|
-
for (const row of this.stmts.iterateTombstones.iterate()) {
|
|
489
|
-
yield [row.id, row.clock]
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Transaction implementation for SQLiteSyncStorage.
|
|
496
|
-
* Provides access to documents, tombstones, and metadata within a transaction.
|
|
497
|
-
*
|
|
498
|
-
* @internal
|
|
499
|
-
*/
|
|
500
|
-
class SQLiteSyncStorageTransaction<R extends UnknownRecord> implements TLSyncStorageTransaction<R> {
|
|
501
|
-
private _clock: number
|
|
502
|
-
private _closed = false
|
|
503
|
-
private _didIncrementClock: boolean = false
|
|
504
|
-
|
|
505
|
-
constructor(
|
|
506
|
-
private storage: SQLiteSyncStorage<R>,
|
|
507
|
-
private stmts: SQLiteSyncStorage<R>['stmts']
|
|
508
|
-
) {
|
|
509
|
-
this._clock = this.storage.getClock()
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** @internal */
|
|
513
|
-
close() {
|
|
514
|
-
this._closed = true
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
private assertNotClosed() {
|
|
518
|
-
assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
getClock(): number {
|
|
522
|
-
return this._clock
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
private getNextClock(): number {
|
|
526
|
-
if (!this._didIncrementClock) {
|
|
527
|
-
this._didIncrementClock = true
|
|
528
|
-
this.stmts.incrementDocumentClock.run()
|
|
529
|
-
this._clock = this.storage.getClock()
|
|
530
|
-
}
|
|
531
|
-
return this._clock
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
get(id: string): R | undefined {
|
|
535
|
-
this.assertNotClosed()
|
|
536
|
-
const row = this.stmts.getDocument.all(id)[0]
|
|
537
|
-
if (!row) return undefined
|
|
538
|
-
return decodeState<R>(row.state)
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
set(id: string, record: R): void {
|
|
542
|
-
this.assertNotClosed()
|
|
543
|
-
assert(id === record.id, `Record id mismatch: key does not match record.id`)
|
|
544
|
-
const clock = this.getNextClock()
|
|
545
|
-
// Automatically clear tombstone if it exists
|
|
546
|
-
this.stmts.deleteTombstone.run(id)
|
|
547
|
-
this.stmts.insertDocument.run(id, encodeState(record), clock)
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
delete(id: string): void {
|
|
551
|
-
this.assertNotClosed()
|
|
552
|
-
// Only create a tombstone if the record actually exists
|
|
553
|
-
const exists = this.stmts.documentExists.all(id)[0]
|
|
554
|
-
if (!exists) return
|
|
555
|
-
const clock = this.getNextClock()
|
|
556
|
-
this.stmts.deleteDocument.run(id)
|
|
557
|
-
this.stmts.insertTombstone.run(id, clock)
|
|
558
|
-
this.storage.pruneTombstones()
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
*entries(): IterableIterator<[string, R]> {
|
|
562
|
-
this.assertNotClosed()
|
|
563
|
-
for (const row of this.stmts.iterateDocumentEntries.iterate()) {
|
|
564
|
-
this.assertNotClosed()
|
|
565
|
-
yield [row.id, decodeState<R>(row.state)]
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
*keys(): IterableIterator<string> {
|
|
570
|
-
this.assertNotClosed()
|
|
571
|
-
for (const row of this.stmts.iterateDocumentKeys.iterate()) {
|
|
572
|
-
this.assertNotClosed()
|
|
573
|
-
yield row.id
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
*values(): IterableIterator<R> {
|
|
578
|
-
this.assertNotClosed()
|
|
579
|
-
for (const row of this.stmts.iterateDocumentValues.iterate()) {
|
|
580
|
-
this.assertNotClosed()
|
|
581
|
-
yield decodeState<R>(row.state)
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
getSchema(): SerializedSchema {
|
|
586
|
-
this.assertNotClosed()
|
|
587
|
-
return this.storage._getSchema()
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
setSchema(schema: SerializedSchema): void {
|
|
591
|
-
this.assertNotClosed()
|
|
592
|
-
this.storage._setSchema(schema)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
|
|
596
|
-
this.assertNotClosed()
|
|
597
|
-
const clock = this.storage.getClock()
|
|
598
|
-
if (sinceClock === clock) return undefined
|
|
599
|
-
if (sinceClock > clock) {
|
|
600
|
-
// something went wrong, wipe the slate clean
|
|
601
|
-
sinceClock = -1
|
|
602
|
-
}
|
|
603
|
-
const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
|
|
604
|
-
const wipeAll = sinceClock < this.storage._getTombstoneHistoryStartsAtClock()
|
|
605
|
-
|
|
606
|
-
if (wipeAll) {
|
|
607
|
-
// If wipeAll, include all documents
|
|
608
|
-
for (const row of this.stmts.iterateDocumentValues.iterate()) {
|
|
609
|
-
const state = decodeState<R>(row.state)
|
|
610
|
-
diff.puts[state.id] = state
|
|
611
|
-
}
|
|
612
|
-
} else {
|
|
613
|
-
// Get documents changed since clock
|
|
614
|
-
for (const row of this.stmts.getDocumentsChangedSince.iterate(sinceClock)) {
|
|
615
|
-
const state = decodeState<R>(row.state)
|
|
616
|
-
diff.puts[state.id] = state
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Get tombstones changed since clock
|
|
621
|
-
for (const row of this.stmts.getTombstonesChangedSince.iterate(sinceClock)) {
|
|
622
|
-
diff.deletes.push(row.id)
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
return { diff, wipeAll }
|
|
626
|
-
}
|
|
627
|
-
}
|