@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
package/src/lib/TLSyncStorage.ts
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'
|
|
2
|
-
import { isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'
|
|
3
|
-
import { TLStoreSnapshot } from 'tldraw'
|
|
4
|
-
import { diffRecord, NetworkDiff, RecordOpType } from './diff'
|
|
5
|
-
import { RoomSnapshot } from './TLSyncRoom'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Transaction interface for storage operations. Provides methods to read and modify
|
|
9
|
-
* documents, tombstones, and metadata within a transaction.
|
|
10
|
-
*
|
|
11
|
-
* @public
|
|
12
|
-
*/
|
|
13
|
-
export interface TLSyncStorageTransaction<R extends UnknownRecord> extends SynchronousStorage<R> {
|
|
14
|
-
/**
|
|
15
|
-
* Get the current clock value.
|
|
16
|
-
* If the clock has incremented during the transaction,
|
|
17
|
-
* the incremented value will be returned.
|
|
18
|
-
*
|
|
19
|
-
* @returns The current clock value
|
|
20
|
-
*/
|
|
21
|
-
getClock(): number
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Get all changes (document updates and deletions) since a given clock time.
|
|
25
|
-
* This is the main method for calculating diffs for client sync.
|
|
26
|
-
*
|
|
27
|
-
* @param sinceClock - The clock time to get changes since
|
|
28
|
-
* @returns Changes since the specified clock time
|
|
29
|
-
*/
|
|
30
|
-
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Options for a transaction.
|
|
35
|
-
* @public
|
|
36
|
-
*/
|
|
37
|
-
export interface TLSyncStorageTransactionOptions {
|
|
38
|
-
/**
|
|
39
|
-
* Use this if you need to identify the transaction for logging or debugging purposes
|
|
40
|
-
* or for ignoring certain changes in onChange callbacks
|
|
41
|
-
*/
|
|
42
|
-
id?: string
|
|
43
|
-
/**
|
|
44
|
-
* Controls when the storage layer should emit the actual changes that occurred during the transaction.
|
|
45
|
-
*
|
|
46
|
-
* - `'always'` - Always emit the changes, regardless of whether they were applied verbatim
|
|
47
|
-
* - `'when-different'` - Only emit changes if the storage layer modified/embellished the records
|
|
48
|
-
* (e.g., added server timestamps, normalized data, etc.)
|
|
49
|
-
*
|
|
50
|
-
* When changes are emitted, they will be available in the `changes` field of the transaction result.
|
|
51
|
-
* This is useful when the storage layer may transform records and the caller needs to know
|
|
52
|
-
* what actually changed rather than what was requested.
|
|
53
|
-
*/
|
|
54
|
-
emitChanges?: 'always' | 'when-different'
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Callback type for a transaction.
|
|
59
|
-
* The conditional return type ensures that the callback is synchronous.
|
|
60
|
-
* @public
|
|
61
|
-
*/
|
|
62
|
-
export type TLSyncStorageTransactionCallback<R extends UnknownRecord, T> = (
|
|
63
|
-
txn: TLSyncStorageTransaction<R>
|
|
64
|
-
) => T extends Promise<any>
|
|
65
|
-
? {
|
|
66
|
-
__error: 'Transaction callbacks cannot be async. Use synchronous operations only.'
|
|
67
|
-
}
|
|
68
|
-
: T
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Pluggable synchronous transactional storage layer for TLSyncRoom.
|
|
72
|
-
* Provides methods for managing documents, tombstones, and clocks within transactions.
|
|
73
|
-
*
|
|
74
|
-
* @public
|
|
75
|
-
*/
|
|
76
|
-
export interface TLSyncStorage<R extends UnknownRecord> {
|
|
77
|
-
transaction<T>(
|
|
78
|
-
callback: TLSyncStorageTransactionCallback<R, T>,
|
|
79
|
-
opts?: TLSyncStorageTransactionOptions
|
|
80
|
-
): TLSyncStorageTransactionResult<T, R>
|
|
81
|
-
|
|
82
|
-
getClock(): number
|
|
83
|
-
|
|
84
|
-
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void
|
|
85
|
-
|
|
86
|
-
getSnapshot?(): RoomSnapshot
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Properties passed to the onChange callback.
|
|
91
|
-
* @public
|
|
92
|
-
*/
|
|
93
|
-
export interface TLSyncStorageOnChangeCallbackProps {
|
|
94
|
-
/**
|
|
95
|
-
* The ID of the transaction that caused the change.
|
|
96
|
-
* This is useful for ignoring certain changes in onChange callbacks.
|
|
97
|
-
*/
|
|
98
|
-
id?: string
|
|
99
|
-
documentClock: number
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Result returned from a storage transaction.
|
|
104
|
-
* @public
|
|
105
|
-
*/
|
|
106
|
-
export interface TLSyncStorageTransactionResult<T, R extends UnknownRecord = UnknownRecord> {
|
|
107
|
-
documentClock: number
|
|
108
|
-
didChange: boolean
|
|
109
|
-
result: T
|
|
110
|
-
/**
|
|
111
|
-
* The actual changes that occurred during the transaction, if requested via `emitChanges` option.
|
|
112
|
-
* This is a RecordsDiff where:
|
|
113
|
-
* - `added` contains records that were put (we don't have "from" state for emitted changes)
|
|
114
|
-
* - `removed` contains records that were deleted (with placeholder values since we only have IDs)
|
|
115
|
-
* - `updated` is empty (emitted changes don't track before/after pairs)
|
|
116
|
-
*
|
|
117
|
-
* Only populated when:
|
|
118
|
-
* - `emitChanges: 'always'` was specified, or
|
|
119
|
-
* - `emitChanges: 'when-different'` was specified and the storage layer modified records
|
|
120
|
-
*/
|
|
121
|
-
changes?: TLSyncForwardDiff<R>
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Respresents a diff of puts and deletes.
|
|
126
|
-
* @public
|
|
127
|
-
*/
|
|
128
|
-
export interface TLSyncForwardDiff<R extends UnknownRecord> {
|
|
129
|
-
puts: Record<string, R | [before: R, after: R]>
|
|
130
|
-
deletes: string[]
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* @internal
|
|
135
|
-
*/
|
|
136
|
-
export function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {
|
|
137
|
-
const networkDiff: NetworkDiff<R> = {}
|
|
138
|
-
for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
|
|
139
|
-
if (Array.isArray(put)) {
|
|
140
|
-
const patch = diffRecord(put[0], put[1])
|
|
141
|
-
if (patch) {
|
|
142
|
-
networkDiff[id] = [RecordOpType.Patch, patch]
|
|
143
|
-
}
|
|
144
|
-
} else {
|
|
145
|
-
networkDiff[id] = [RecordOpType.Put, put]
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (const id of diff.deletes) {
|
|
149
|
-
networkDiff[id] = [RecordOpType.Remove]
|
|
150
|
-
}
|
|
151
|
-
return networkDiff
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Result returned from getChangesSince, containing all changes since a given clock time.
|
|
156
|
-
* @public
|
|
157
|
-
*/
|
|
158
|
-
export interface TLSyncStorageGetChangesSinceResult<R extends UnknownRecord> {
|
|
159
|
-
/**
|
|
160
|
-
* The changes as a TLSyncForwardDiff.
|
|
161
|
-
*/
|
|
162
|
-
diff: TLSyncForwardDiff<R>
|
|
163
|
-
/**
|
|
164
|
-
* If true, the client should wipe all local data and replace with the server's state.
|
|
165
|
-
* This happens when the client's clock is too old and we've lost tombstone history.
|
|
166
|
-
*/
|
|
167
|
-
wipeAll: boolean
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Loads a snapshot into storage during a transaction.
|
|
172
|
-
* Migrates the snapshot to the current schema and loads it into storage.
|
|
173
|
-
*
|
|
174
|
-
* @public
|
|
175
|
-
* @param txn - The transaction to load the snapshot into
|
|
176
|
-
* @param schema - The current schema
|
|
177
|
-
* @param snapshot - The snapshot to load
|
|
178
|
-
*/
|
|
179
|
-
export function loadSnapshotIntoStorage<R extends UnknownRecord>(
|
|
180
|
-
txn: TLSyncStorageTransaction<R>,
|
|
181
|
-
schema: StoreSchema<R, any>,
|
|
182
|
-
snapshot: RoomSnapshot | TLStoreSnapshot
|
|
183
|
-
) {
|
|
184
|
-
snapshot = convertStoreSnapshotToRoomSnapshot(snapshot)
|
|
185
|
-
assert(snapshot.schema, 'Schema is required')
|
|
186
|
-
const docIds = new Set<string>()
|
|
187
|
-
for (const doc of snapshot.documents) {
|
|
188
|
-
docIds.add(doc.state.id)
|
|
189
|
-
const existing = txn.get(doc.state.id)
|
|
190
|
-
if (isEqual(existing, doc.state)) continue
|
|
191
|
-
txn.set(doc.state.id, doc.state as R)
|
|
192
|
-
}
|
|
193
|
-
for (const id of txn.keys()) {
|
|
194
|
-
if (!docIds.has(id)) {
|
|
195
|
-
txn.delete(id)
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
txn.setSchema(snapshot.schema)
|
|
199
|
-
schema.migrateStorage(txn)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export function convertStoreSnapshotToRoomSnapshot(
|
|
203
|
-
snapshot: RoomSnapshot | TLStoreSnapshot
|
|
204
|
-
): RoomSnapshot {
|
|
205
|
-
if ('documents' in snapshot) return snapshot
|
|
206
|
-
return {
|
|
207
|
-
clock: 0,
|
|
208
|
-
documentClock: 0,
|
|
209
|
-
documents: objectMapValues(snapshot.store).map((state) => ({
|
|
210
|
-
state,
|
|
211
|
-
lastChangedClock: 0,
|
|
212
|
-
})),
|
|
213
|
-
schema: snapshot.schema,
|
|
214
|
-
tombstones: {},
|
|
215
|
-
}
|
|
216
|
-
}
|
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
computeTombstonePruning,
|
|
4
|
-
MAX_TOMBSTONES,
|
|
5
|
-
TOMBSTONE_PRUNE_BUFFER_SIZE,
|
|
6
|
-
} from './InMemorySyncStorage'
|
|
7
|
-
|
|
8
|
-
// Helper to create tombstone array
|
|
9
|
-
function makeTombstones(
|
|
10
|
-
count: number,
|
|
11
|
-
clockFn: (i: number) => number = (i) => i + 1
|
|
12
|
-
): Array<{ id: string; clock: number }> {
|
|
13
|
-
return Array.from({ length: count }, (_, i) => ({
|
|
14
|
-
id: `doc${i}`,
|
|
15
|
-
clock: clockFn(i),
|
|
16
|
-
}))
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('computeTombstonePruning', () => {
|
|
20
|
-
describe('basic behavior', () => {
|
|
21
|
-
it('returns null when tombstone count is below threshold', () => {
|
|
22
|
-
const tombstones = makeTombstones(100)
|
|
23
|
-
const result = computeTombstonePruning({
|
|
24
|
-
tombstones,
|
|
25
|
-
documentClock: 1000,
|
|
26
|
-
maxTombstones: 500,
|
|
27
|
-
})
|
|
28
|
-
expect(result).toBeNull()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('returns null when tombstone count equals threshold exactly', () => {
|
|
32
|
-
const tombstones = makeTombstones(500)
|
|
33
|
-
const result = computeTombstonePruning({
|
|
34
|
-
tombstones,
|
|
35
|
-
documentClock: 1000,
|
|
36
|
-
maxTombstones: 500,
|
|
37
|
-
})
|
|
38
|
-
expect(result).toBeNull()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('returns null for empty tombstones array', () => {
|
|
42
|
-
const result = computeTombstonePruning({ tombstones: [], documentClock: 1000 })
|
|
43
|
-
expect(result).toBeNull()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('returns pruning result when exceeding threshold', () => {
|
|
47
|
-
const tombstones = makeTombstones(600)
|
|
48
|
-
const result = computeTombstonePruning({
|
|
49
|
-
tombstones,
|
|
50
|
-
documentClock: 1000,
|
|
51
|
-
maxTombstones: 500,
|
|
52
|
-
pruneBufferSize: 50,
|
|
53
|
-
})
|
|
54
|
-
expect(result).not.toBeNull()
|
|
55
|
-
expect(result!.idsToDelete.length).toBeGreaterThan(0)
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
describe('pruning calculation', () => {
|
|
60
|
-
it('deletes oldest tombstones first', () => {
|
|
61
|
-
// 10 tombstones, max 5, buffer 2 => delete 10 - 5 + 2 = 7
|
|
62
|
-
const tombstones = makeTombstones(10)
|
|
63
|
-
const result = computeTombstonePruning({
|
|
64
|
-
tombstones,
|
|
65
|
-
documentClock: 100,
|
|
66
|
-
maxTombstones: 5,
|
|
67
|
-
pruneBufferSize: 2,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
expect(result).not.toBeNull()
|
|
71
|
-
expect(result!.idsToDelete).toEqual(['doc0', 'doc1', 'doc2', 'doc3', 'doc4', 'doc5', 'doc6'])
|
|
72
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(8) // clock of doc7
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('keeps newest tombstones', () => {
|
|
76
|
-
const tombstones = makeTombstones(10)
|
|
77
|
-
const result = computeTombstonePruning({
|
|
78
|
-
tombstones,
|
|
79
|
-
documentClock: 100,
|
|
80
|
-
maxTombstones: 5,
|
|
81
|
-
pruneBufferSize: 2,
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// Should keep doc7, doc8, doc9
|
|
85
|
-
const keptIds = tombstones.map((t) => t.id).filter((id) => !result!.idsToDelete.includes(id))
|
|
86
|
-
expect(keptIds).toEqual(['doc7', 'doc8', 'doc9'])
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('sets newTombstoneHistoryStartsAtClock to oldest remaining tombstone clock', () => {
|
|
90
|
-
// Tombstones with clocks 1-20
|
|
91
|
-
const tombstones = makeTombstones(20)
|
|
92
|
-
// max 10, buffer 5 => delete 20 - 10 + 5 = 15
|
|
93
|
-
const result = computeTombstonePruning({
|
|
94
|
-
tombstones,
|
|
95
|
-
documentClock: 1000,
|
|
96
|
-
maxTombstones: 10,
|
|
97
|
-
pruneBufferSize: 5,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
expect(result).not.toBeNull()
|
|
101
|
-
// After deleting 15, oldest remaining is doc15 with clock 16
|
|
102
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(16)
|
|
103
|
-
})
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
describe('duplicate clock handling', () => {
|
|
107
|
-
it('avoids splitting tombstones with the same clock value', () => {
|
|
108
|
-
// Create tombstones where multiple have the same clock
|
|
109
|
-
// Clocks: [1, 1, 1, 2, 2, 2, 3, 3, 3, 4]
|
|
110
|
-
const tombstones = [
|
|
111
|
-
{ id: 'a1', clock: 1 },
|
|
112
|
-
{ id: 'a2', clock: 1 },
|
|
113
|
-
{ id: 'a3', clock: 1 },
|
|
114
|
-
{ id: 'b1', clock: 2 },
|
|
115
|
-
{ id: 'b2', clock: 2 },
|
|
116
|
-
{ id: 'b3', clock: 2 },
|
|
117
|
-
{ id: 'c1', clock: 3 },
|
|
118
|
-
{ id: 'c2', clock: 3 },
|
|
119
|
-
{ id: 'c3', clock: 3 },
|
|
120
|
-
{ id: 'd1', clock: 4 },
|
|
121
|
-
]
|
|
122
|
-
|
|
123
|
-
// max 5, buffer 1 => initial cutoff = 10 - 5 + 1 = 6
|
|
124
|
-
// cutoff 6 would split clock=2 (b3) from clock=3 (c1)
|
|
125
|
-
// But tombstones[5].clock (2) !== tombstones[6].clock (3), so no adjustment needed
|
|
126
|
-
const result = computeTombstonePruning({
|
|
127
|
-
tombstones,
|
|
128
|
-
documentClock: 100,
|
|
129
|
-
maxTombstones: 5,
|
|
130
|
-
pruneBufferSize: 1,
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
expect(result).not.toBeNull()
|
|
134
|
-
expect(result!.idsToDelete).toEqual(['a1', 'a2', 'a3', 'b1', 'b2', 'b3'])
|
|
135
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(3) // clock of c1
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('extends cutoff when it would split a clock value', () => {
|
|
139
|
-
// Clocks: [1, 2, 2, 2, 2, 3]
|
|
140
|
-
const tombstones = [
|
|
141
|
-
{ id: 'a', clock: 1 },
|
|
142
|
-
{ id: 'b1', clock: 2 },
|
|
143
|
-
{ id: 'b2', clock: 2 },
|
|
144
|
-
{ id: 'b3', clock: 2 },
|
|
145
|
-
{ id: 'b4', clock: 2 },
|
|
146
|
-
{ id: 'c', clock: 3 },
|
|
147
|
-
]
|
|
148
|
-
|
|
149
|
-
// max 4, buffer 1 => initial cutoff = 6 - 4 + 1 = 3
|
|
150
|
-
// cutoff 3 points to b3 (clock 2), cutoff-1 points to b2 (clock 2)
|
|
151
|
-
// Same clock, so extend cutoff until boundary
|
|
152
|
-
// cutoff 4: b4 (clock 2), cutoff-1: b3 (clock 2) - same, extend
|
|
153
|
-
// cutoff 5: c (clock 3), cutoff-1: b4 (clock 2) - different, stop
|
|
154
|
-
const result = computeTombstonePruning({
|
|
155
|
-
tombstones,
|
|
156
|
-
documentClock: 100,
|
|
157
|
-
maxTombstones: 4,
|
|
158
|
-
pruneBufferSize: 1,
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
expect(result).not.toBeNull()
|
|
162
|
-
// Must delete all clock=2 tombstones to avoid split
|
|
163
|
-
expect(result!.idsToDelete).toEqual(['a', 'b1', 'b2', 'b3', 'b4'])
|
|
164
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(3)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('handles all tombstones with same clock value', () => {
|
|
168
|
-
// All tombstones have clock=5
|
|
169
|
-
const tombstones = makeTombstones(100, () => 5)
|
|
170
|
-
const result = computeTombstonePruning({
|
|
171
|
-
tombstones,
|
|
172
|
-
documentClock: 1000,
|
|
173
|
-
maxTombstones: 50,
|
|
174
|
-
pruneBufferSize: 10,
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
expect(result).not.toBeNull()
|
|
178
|
-
// Since all have same clock, cutoff extends to delete all
|
|
179
|
-
expect(result!.idsToDelete.length).toBe(100)
|
|
180
|
-
// Falls back to documentClock since no remaining tombstones
|
|
181
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(1000)
|
|
182
|
-
})
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
describe('edge cases', () => {
|
|
186
|
-
it('handles exactly one tombstone over threshold', () => {
|
|
187
|
-
const tombstones = makeTombstones(101)
|
|
188
|
-
const result = computeTombstonePruning({
|
|
189
|
-
tombstones,
|
|
190
|
-
documentClock: 1000,
|
|
191
|
-
maxTombstones: 100,
|
|
192
|
-
pruneBufferSize: 0,
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
expect(result).not.toBeNull()
|
|
196
|
-
expect(result!.idsToDelete).toEqual(['doc0'])
|
|
197
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(2)
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
it('handles buffer size larger than excess', () => {
|
|
201
|
-
// 105 tombstones, max 100, buffer 50
|
|
202
|
-
// cutoff = 50 + 105 - 100 = 55
|
|
203
|
-
const tombstones = makeTombstones(105)
|
|
204
|
-
const result = computeTombstonePruning({
|
|
205
|
-
tombstones,
|
|
206
|
-
documentClock: 1000,
|
|
207
|
-
maxTombstones: 100,
|
|
208
|
-
pruneBufferSize: 50,
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
expect(result).not.toBeNull()
|
|
212
|
-
expect(result!.idsToDelete.length).toBe(55)
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('uses documentClock when all tombstones are deleted', () => {
|
|
216
|
-
// Small array, aggressive pruning
|
|
217
|
-
const tombstones = makeTombstones(10)
|
|
218
|
-
// max 5, buffer 10 => cutoff = 10 + 10 - 5 = 15, but only 10 items
|
|
219
|
-
// So all get deleted
|
|
220
|
-
const result = computeTombstonePruning({
|
|
221
|
-
tombstones,
|
|
222
|
-
documentClock: 999,
|
|
223
|
-
maxTombstones: 5,
|
|
224
|
-
pruneBufferSize: 10,
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
expect(result).not.toBeNull()
|
|
228
|
-
expect(result!.idsToDelete.length).toBe(10)
|
|
229
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(999)
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
it('handles non-contiguous clock values', () => {
|
|
233
|
-
// Clocks with gaps: [1, 5, 10, 50, 100]
|
|
234
|
-
const tombstones = [
|
|
235
|
-
{ id: 'a', clock: 1 },
|
|
236
|
-
{ id: 'b', clock: 5 },
|
|
237
|
-
{ id: 'c', clock: 10 },
|
|
238
|
-
{ id: 'd', clock: 50 },
|
|
239
|
-
{ id: 'e', clock: 100 },
|
|
240
|
-
]
|
|
241
|
-
const result = computeTombstonePruning({
|
|
242
|
-
tombstones,
|
|
243
|
-
documentClock: 200,
|
|
244
|
-
maxTombstones: 3,
|
|
245
|
-
pruneBufferSize: 1,
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
expect(result).not.toBeNull()
|
|
249
|
-
// cutoff = 1 + 5 - 3 = 3
|
|
250
|
-
expect(result!.idsToDelete).toEqual(['a', 'b', 'c'])
|
|
251
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(50)
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('handles large clock values', () => {
|
|
255
|
-
const tombstones = [
|
|
256
|
-
{ id: 'a', clock: 1_000_000_000 },
|
|
257
|
-
{ id: 'b', clock: 1_000_000_001 },
|
|
258
|
-
{ id: 'c', clock: 1_000_000_002 },
|
|
259
|
-
]
|
|
260
|
-
const result = computeTombstonePruning({
|
|
261
|
-
tombstones,
|
|
262
|
-
documentClock: 2000000000,
|
|
263
|
-
maxTombstones: 2,
|
|
264
|
-
pruneBufferSize: 0,
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
expect(result).not.toBeNull()
|
|
268
|
-
expect(result!.idsToDelete).toEqual(['a'])
|
|
269
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(1_000_000_001)
|
|
270
|
-
})
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
describe('with default constants', () => {
|
|
274
|
-
it('does not prune at exactly MAX_TOMBSTONES', () => {
|
|
275
|
-
const tombstones = makeTombstones(MAX_TOMBSTONES)
|
|
276
|
-
const result = computeTombstonePruning({ tombstones, documentClock: 100000 })
|
|
277
|
-
expect(result).toBeNull()
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('prunes when exceeding MAX_TOMBSTONES by 1', () => {
|
|
281
|
-
const tombstones = makeTombstones(MAX_TOMBSTONES + 1)
|
|
282
|
-
const result = computeTombstonePruning({ tombstones, documentClock: 100000 })
|
|
283
|
-
|
|
284
|
-
expect(result).not.toBeNull()
|
|
285
|
-
// cutoff = BUFFER + (MAX+1) - MAX = BUFFER + 1
|
|
286
|
-
expect(result!.idsToDelete.length).toBe(TOMBSTONE_PRUNE_BUFFER_SIZE + 1)
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('prunes correctly with large excess', () => {
|
|
290
|
-
const totalTombstones = MAX_TOMBSTONES * 2
|
|
291
|
-
const tombstones = makeTombstones(totalTombstones)
|
|
292
|
-
const result = computeTombstonePruning({ tombstones, documentClock: 100000 })
|
|
293
|
-
|
|
294
|
-
expect(result).not.toBeNull()
|
|
295
|
-
// cutoff = BUFFER + 2*MAX - MAX = BUFFER + MAX
|
|
296
|
-
const expectedDeletes = TOMBSTONE_PRUNE_BUFFER_SIZE + MAX_TOMBSTONES
|
|
297
|
-
expect(result!.idsToDelete.length).toBe(expectedDeletes)
|
|
298
|
-
|
|
299
|
-
// Remaining should be MAX - BUFFER
|
|
300
|
-
const remaining = totalTombstones - result!.idsToDelete.length
|
|
301
|
-
expect(remaining).toBe(MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE)
|
|
302
|
-
})
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
describe('input validation assumptions', () => {
|
|
306
|
-
it('assumes tombstones are sorted by clock ascending', () => {
|
|
307
|
-
// If not sorted, results are undefined - this documents the assumption
|
|
308
|
-
const sortedTombstones = [
|
|
309
|
-
{ id: 'a', clock: 1 },
|
|
310
|
-
{ id: 'b', clock: 2 },
|
|
311
|
-
{ id: 'c', clock: 3 },
|
|
312
|
-
]
|
|
313
|
-
const result = computeTombstonePruning({
|
|
314
|
-
tombstones: sortedTombstones,
|
|
315
|
-
documentClock: 100,
|
|
316
|
-
maxTombstones: 2,
|
|
317
|
-
pruneBufferSize: 0,
|
|
318
|
-
})
|
|
319
|
-
expect(result).not.toBeNull()
|
|
320
|
-
expect(result!.idsToDelete).toEqual(['a'])
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
it('works with zero buffer size', () => {
|
|
324
|
-
const tombstones = makeTombstones(10)
|
|
325
|
-
const result = computeTombstonePruning({
|
|
326
|
-
tombstones,
|
|
327
|
-
documentClock: 100,
|
|
328
|
-
maxTombstones: 5,
|
|
329
|
-
pruneBufferSize: 0,
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
expect(result).not.toBeNull()
|
|
333
|
-
// cutoff = 0 + 10 - 5 = 5
|
|
334
|
-
expect(result!.idsToDelete.length).toBe(5)
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
it('works with zero max tombstones (aggressive pruning)', () => {
|
|
338
|
-
const tombstones = makeTombstones(10)
|
|
339
|
-
const result = computeTombstonePruning({
|
|
340
|
-
tombstones,
|
|
341
|
-
documentClock: 100,
|
|
342
|
-
maxTombstones: 0,
|
|
343
|
-
pruneBufferSize: 0,
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
expect(result).not.toBeNull()
|
|
347
|
-
// cutoff = 0 + 10 - 0 = 10
|
|
348
|
-
expect(result!.idsToDelete.length).toBe(10)
|
|
349
|
-
expect(result!.newTombstoneHistoryStartsAtClock).toBe(100)
|
|
350
|
-
})
|
|
351
|
-
})
|
|
352
|
-
})
|
package/src/lib/recordDiff.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { RecordType, UnknownRecord } from '@tldraw/store'
|
|
2
|
-
import { ObjectDiff, applyObjectDiff, diffRecord } from './diff'
|
|
3
|
-
import { TLSyncError, TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Validate a record and compute the diff between two states.
|
|
7
|
-
* Returns null if the states are identical.
|
|
8
|
-
*
|
|
9
|
-
* @param prevState - The previous record state
|
|
10
|
-
* @param newState - The new record state
|
|
11
|
-
* @param recordType - The record type definition for validation
|
|
12
|
-
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
13
|
-
* @returns Result containing the diff and new state, or null if no changes, or validation error
|
|
14
|
-
*
|
|
15
|
-
* @internal
|
|
16
|
-
*/
|
|
17
|
-
export function diffAndValidateRecord<R extends UnknownRecord>(
|
|
18
|
-
prevState: R,
|
|
19
|
-
newState: R,
|
|
20
|
-
recordType: RecordType<R, any>,
|
|
21
|
-
legacyAppendMode = false
|
|
22
|
-
) {
|
|
23
|
-
const diff = diffRecord(prevState, newState, legacyAppendMode)
|
|
24
|
-
if (!diff) return
|
|
25
|
-
try {
|
|
26
|
-
recordType.validate(newState)
|
|
27
|
-
} catch (error: any) {
|
|
28
|
-
throw new TLSyncError(error.message, TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
29
|
-
}
|
|
30
|
-
return diff
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Apply a diff to a record state, validate the result, and compute the final diff.
|
|
35
|
-
* Returns null if the diff produces no changes.
|
|
36
|
-
*
|
|
37
|
-
* @param prevState - The previous record state
|
|
38
|
-
* @param diff - The object diff to apply
|
|
39
|
-
* @param recordType - The record type definition for validation
|
|
40
|
-
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
41
|
-
* @returns Result containing the final diff and new state, or null if no changes, or validation error
|
|
42
|
-
*
|
|
43
|
-
* @internal
|
|
44
|
-
*/
|
|
45
|
-
export function applyAndDiffRecord<R extends UnknownRecord>(
|
|
46
|
-
prevState: R,
|
|
47
|
-
diff: ObjectDiff,
|
|
48
|
-
recordType: RecordType<R, any>,
|
|
49
|
-
legacyAppendMode = false
|
|
50
|
-
): [ObjectDiff, R] | undefined {
|
|
51
|
-
const newState = applyObjectDiff(prevState, diff)
|
|
52
|
-
if (newState === prevState) return
|
|
53
|
-
const actualDiff = diffAndValidateRecord(prevState, newState, recordType, legacyAppendMode)
|
|
54
|
-
if (!actualDiff) return
|
|
55
|
-
return [actualDiff, newState]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Validate a record without computing a diff. Used when creating new records.
|
|
60
|
-
*
|
|
61
|
-
* @param state - The record state to validate
|
|
62
|
-
* @param recordType - The record type definition for validation
|
|
63
|
-
* @returns Result indicating success or validation error
|
|
64
|
-
*
|
|
65
|
-
* @internal
|
|
66
|
-
*/
|
|
67
|
-
export function validateRecord<R extends UnknownRecord>(state: R, recordType: RecordType<R, any>) {
|
|
68
|
-
try {
|
|
69
|
-
recordType.validate(state)
|
|
70
|
-
} catch (error: any) {
|
|
71
|
-
throw new TLSyncError(error.message, TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
72
|
-
}
|
|
73
|
-
}
|