@tldraw/store 4.1.0-canary.e2133d922c9e → 4.1.0-canary.e259b517a450
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 +1884 -153
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/AtomMap.js +241 -1
- package/dist-cjs/lib/AtomMap.js.map +2 -2
- package/dist-cjs/lib/BaseRecord.js.map +2 -2
- package/dist-cjs/lib/ImmutableMap.js +141 -0
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/IncrementalSetConstructor.js +45 -5
- package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +116 -21
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +233 -39
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreQueries.js +135 -22
- package/dist-cjs/lib/StoreQueries.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +207 -2
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/StoreSideEffects.js +102 -10
- package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-cjs/lib/migrate.js.map +2 -2
- package/dist-cjs/lib/setUtils.js.map +2 -2
- package/dist-esm/index.d.mts +1884 -153
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/AtomMap.mjs +241 -1
- package/dist-esm/lib/AtomMap.mjs.map +2 -2
- package/dist-esm/lib/BaseRecord.mjs.map +2 -2
- package/dist-esm/lib/ImmutableMap.mjs +141 -0
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/IncrementalSetConstructor.mjs +45 -5
- package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +116 -21
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +233 -39
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreQueries.mjs +135 -22
- package/dist-esm/lib/StoreQueries.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +207 -2
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/StoreSideEffects.mjs +102 -10
- package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/dist-esm/lib/migrate.mjs.map +2 -2
- package/dist-esm/lib/setUtils.mjs.map +2 -2
- package/package.json +3 -3
- package/src/lib/AtomMap.ts +241 -1
- package/src/lib/BaseRecord.test.ts +44 -0
- package/src/lib/BaseRecord.ts +118 -4
- package/src/lib/ImmutableMap.test.ts +103 -0
- package/src/lib/ImmutableMap.ts +212 -0
- package/src/lib/IncrementalSetConstructor.test.ts +111 -0
- package/src/lib/IncrementalSetConstructor.ts +63 -6
- package/src/lib/RecordType.ts +149 -25
- package/src/lib/RecordsDiff.test.ts +144 -0
- package/src/lib/RecordsDiff.ts +145 -10
- package/src/lib/Store.test.ts +827 -0
- package/src/lib/Store.ts +533 -67
- package/src/lib/StoreQueries.test.ts +627 -0
- package/src/lib/StoreQueries.ts +194 -27
- package/src/lib/StoreSchema.test.ts +226 -0
- package/src/lib/StoreSchema.ts +386 -8
- package/src/lib/StoreSideEffects.test.ts +239 -19
- package/src/lib/StoreSideEffects.ts +266 -19
- package/src/lib/devFreeze.test.ts +137 -0
- package/src/lib/executeQuery.test.ts +481 -0
- package/src/lib/executeQuery.ts +80 -2
- package/src/lib/migrate.test.ts +400 -0
- package/src/lib/migrate.ts +187 -14
- package/src/lib/setUtils.test.ts +105 -0
- package/src/lib/setUtils.ts +44 -4
package/src/lib/RecordType.ts
CHANGED
|
@@ -2,6 +2,17 @@ import { Expand, objectMapEntries, structuredClone, uniqueId } from '@tldraw/uti
|
|
|
2
2
|
import { IdOf, UnknownRecord } from './BaseRecord'
|
|
3
3
|
import { StoreValidator } from './Store'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Utility type that extracts the record type from a RecordType instance.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const Book = createRecordType<BookRecord>('book', { scope: 'document' })
|
|
11
|
+
* type BookFromType = RecordTypeRecord<typeof Book> // BookRecord
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
5
16
|
export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['create']>
|
|
6
17
|
|
|
7
18
|
/**
|
|
@@ -25,12 +36,48 @@ export class RecordType<
|
|
|
25
36
|
R extends UnknownRecord,
|
|
26
37
|
RequiredProperties extends keyof Omit<R, 'id' | 'typeName'>,
|
|
27
38
|
> {
|
|
39
|
+
/**
|
|
40
|
+
* Factory function that creates default properties for new records.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
28
43
|
readonly createDefaultProperties: () => Exclude<Omit<R, 'id' | 'typeName'>, RequiredProperties>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validator function used to validate records of this type.
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
29
49
|
readonly validator: StoreValidator<R>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Optional configuration specifying which record properties are ephemeral.
|
|
53
|
+
* Ephemeral properties are not included in snapshots or synchronization.
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
30
56
|
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set of property names that are marked as ephemeral for efficient lookup.
|
|
60
|
+
* @public
|
|
61
|
+
*/
|
|
31
62
|
readonly ephemeralKeySet: ReadonlySet<string>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The scope that determines how records of this type are persisted and synchronized.
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
32
68
|
readonly scope: RecordScope
|
|
33
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Creates a new RecordType instance.
|
|
72
|
+
*
|
|
73
|
+
* typeName - The unique type name for records created by this RecordType
|
|
74
|
+
* config - Configuration object for the RecordType
|
|
75
|
+
* - createDefaultProperties - Function that returns default properties for new records
|
|
76
|
+
* - validator - Optional validator function for record validation
|
|
77
|
+
* - scope - Optional scope determining persistence behavior (defaults to 'document')
|
|
78
|
+
* - ephemeralKeys - Optional mapping of property names to ephemeral status
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
34
81
|
constructor(
|
|
35
82
|
/**
|
|
36
83
|
* The unique type associated with this record.
|
|
@@ -65,10 +112,23 @@ export class RecordType<
|
|
|
65
112
|
}
|
|
66
113
|
|
|
67
114
|
/**
|
|
68
|
-
*
|
|
115
|
+
* Creates a new record of this type with the given properties.
|
|
116
|
+
*
|
|
117
|
+
* Properties are merged with default properties from the RecordType configuration.
|
|
118
|
+
* If no id is provided, a unique id will be generated automatically.
|
|
69
119
|
*
|
|
70
|
-
* @
|
|
71
|
-
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* const book = Book.create({
|
|
123
|
+
* title: 'The Great Gatsby',
|
|
124
|
+
* author: 'F. Scott Fitzgerald'
|
|
125
|
+
* })
|
|
126
|
+
* // Result: { id: 'book:abc123', typeName: 'book', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', inStock: true }
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* @param properties - The properties for the new record, including both required and optional fields
|
|
130
|
+
* @returns The newly created record with generated id and typeName
|
|
131
|
+
* @public
|
|
72
132
|
*/
|
|
73
133
|
create(
|
|
74
134
|
properties: Expand<Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>>
|
|
@@ -90,10 +150,20 @@ export class RecordType<
|
|
|
90
150
|
}
|
|
91
151
|
|
|
92
152
|
/**
|
|
93
|
-
*
|
|
153
|
+
* Creates a deep copy of an existing record with a new unique id.
|
|
154
|
+
*
|
|
155
|
+
* This method performs a deep clone of all properties while generating a fresh id,
|
|
156
|
+
* making it useful for duplicating records without id conflicts.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* const originalBook = Book.create({ title: '1984', author: 'George Orwell' })
|
|
161
|
+
* const duplicatedBook = Book.clone(originalBook)
|
|
162
|
+
* // duplicatedBook has same properties but different id
|
|
163
|
+
* ```
|
|
94
164
|
*
|
|
95
|
-
* @param record - The record to clone
|
|
96
|
-
* @returns
|
|
165
|
+
* @param record - The record to clone
|
|
166
|
+
* @returns A new record with the same properties but a different id
|
|
97
167
|
* @public
|
|
98
168
|
*/
|
|
99
169
|
clone(record: R): R {
|
|
@@ -117,10 +187,20 @@ export class RecordType<
|
|
|
117
187
|
}
|
|
118
188
|
|
|
119
189
|
/**
|
|
120
|
-
*
|
|
190
|
+
* Extracts the unique identifier part from a full record id.
|
|
191
|
+
*
|
|
192
|
+
* Record ids have the format `typeName:uniquePart`. This method returns just the unique part.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* const bookId = Book.createId() // 'book:abc123'
|
|
197
|
+
* const uniquePart = Book.parseId(bookId) // 'abc123'
|
|
198
|
+
* ```
|
|
121
199
|
*
|
|
122
|
-
* @param id - The id
|
|
123
|
-
* @returns
|
|
200
|
+
* @param id - The full record id to parse
|
|
201
|
+
* @returns The unique identifier portion after the colon
|
|
202
|
+
* @throws Error if the id is not valid for this record type
|
|
203
|
+
* @public
|
|
124
204
|
*/
|
|
125
205
|
parseId(id: IdOf<R>): string {
|
|
126
206
|
if (!this.isId(id)) {
|
|
@@ -131,32 +211,44 @@ export class RecordType<
|
|
|
131
211
|
}
|
|
132
212
|
|
|
133
213
|
/**
|
|
134
|
-
*
|
|
214
|
+
* Type guard that checks whether a record belongs to this RecordType.
|
|
135
215
|
*
|
|
136
|
-
*
|
|
216
|
+
* This method performs a runtime check by comparing the record's typeName
|
|
217
|
+
* against this RecordType's typeName.
|
|
137
218
|
*
|
|
219
|
+
* @example
|
|
138
220
|
* ```ts
|
|
139
|
-
*
|
|
221
|
+
* if (Book.isInstance(someRecord)) {
|
|
222
|
+
* // someRecord is now typed as a book record
|
|
223
|
+
* console.log(someRecord.title)
|
|
224
|
+
* }
|
|
140
225
|
* ```
|
|
141
226
|
*
|
|
142
|
-
* @param record - The record to check
|
|
143
|
-
* @returns
|
|
227
|
+
* @param record - The record to check, may be undefined
|
|
228
|
+
* @returns True if the record is an instance of this record type
|
|
229
|
+
* @public
|
|
144
230
|
*/
|
|
145
231
|
isInstance(record?: UnknownRecord): record is R {
|
|
146
232
|
return record?.typeName === this.typeName
|
|
147
233
|
}
|
|
148
234
|
|
|
149
235
|
/**
|
|
150
|
-
*
|
|
236
|
+
* Type guard that checks whether an id string belongs to this RecordType.
|
|
151
237
|
*
|
|
152
|
-
*
|
|
238
|
+
* Validates that the id starts with this RecordType's typeName followed by a colon.
|
|
239
|
+
* This is more efficient than parsing the full id when you only need to verify the type.
|
|
153
240
|
*
|
|
241
|
+
* @example
|
|
154
242
|
* ```ts
|
|
155
|
-
*
|
|
243
|
+
* if (Book.isId(someId)) {
|
|
244
|
+
* // someId is now typed as IdOf<BookRecord>
|
|
245
|
+
* const book = store.get(someId)
|
|
246
|
+
* }
|
|
156
247
|
* ```
|
|
157
248
|
*
|
|
158
|
-
* @param id - The id to check
|
|
159
|
-
* @returns
|
|
249
|
+
* @param id - The id string to check, may be undefined
|
|
250
|
+
* @returns True if the id belongs to this record type
|
|
251
|
+
* @public
|
|
160
252
|
*/
|
|
161
253
|
isId(id?: string): id is IdOf<R> {
|
|
162
254
|
if (!id) return false
|
|
@@ -193,8 +285,26 @@ export class RecordType<
|
|
|
193
285
|
}
|
|
194
286
|
|
|
195
287
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
288
|
+
* Validates a record against this RecordType's validator and returns it with proper typing.
|
|
289
|
+
*
|
|
290
|
+
* This method runs the configured validator function and throws an error if validation fails.
|
|
291
|
+
* If a previous version of the record is provided, it may use optimized validation.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```ts
|
|
295
|
+
* try {
|
|
296
|
+
* const validBook = Book.validate(untrustedData)
|
|
297
|
+
* // validBook is now properly typed and validated
|
|
298
|
+
* } catch (error) {
|
|
299
|
+
* console.log('Validation failed:', error.message)
|
|
300
|
+
* }
|
|
301
|
+
* ```
|
|
302
|
+
*
|
|
303
|
+
* @param record - The unknown record data to validate
|
|
304
|
+
* @param recordBefore - Optional previous version for optimized validation
|
|
305
|
+
* @returns The validated and properly typed record
|
|
306
|
+
* @throws Error if validation fails
|
|
307
|
+
* @public
|
|
198
308
|
*/
|
|
199
309
|
validate(record: unknown, recordBefore?: R): R {
|
|
200
310
|
if (recordBefore && this.validator.validateUsingKnownGoodVersion) {
|
|
@@ -205,15 +315,29 @@ export class RecordType<
|
|
|
205
315
|
}
|
|
206
316
|
|
|
207
317
|
/**
|
|
208
|
-
*
|
|
318
|
+
* Creates a new RecordType with the specified configuration.
|
|
209
319
|
*
|
|
210
|
-
*
|
|
320
|
+
* This factory function creates a RecordType that can be used to create, validate, and manage
|
|
321
|
+
* records of a specific type within a store. The resulting RecordType can be extended with
|
|
322
|
+
* default properties using the withDefaultProperties method.
|
|
211
323
|
*
|
|
324
|
+
* @example
|
|
212
325
|
* ```ts
|
|
213
|
-
*
|
|
326
|
+
* interface BookRecord extends BaseRecord<'book', RecordId<BookRecord>> {
|
|
327
|
+
* title: string
|
|
328
|
+
* author: string
|
|
329
|
+
* inStock: boolean
|
|
330
|
+
* }
|
|
331
|
+
*
|
|
332
|
+
* const Book = createRecordType<BookRecord>('book', {
|
|
333
|
+
* scope: 'document',
|
|
334
|
+
* validator: bookValidator
|
|
335
|
+
* })
|
|
214
336
|
* ```
|
|
215
337
|
*
|
|
216
|
-
* @param typeName - The name
|
|
338
|
+
* @param typeName - The unique type name for this record type
|
|
339
|
+
* @param config - Configuration object containing validator, scope, and ephemeral keys
|
|
340
|
+
* @returns A new RecordType instance for creating and managing records
|
|
217
341
|
* @public
|
|
218
342
|
*/
|
|
219
343
|
export function createRecordType<R extends UnknownRecord>(
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { BaseRecord, RecordId } from './BaseRecord'
|
|
3
|
+
import {
|
|
4
|
+
RecordsDiff,
|
|
5
|
+
createEmptyRecordsDiff,
|
|
6
|
+
isRecordsDiffEmpty,
|
|
7
|
+
reverseRecordsDiff,
|
|
8
|
+
squashRecordDiffs,
|
|
9
|
+
squashRecordDiffsMutable,
|
|
10
|
+
} from './RecordsDiff'
|
|
11
|
+
|
|
12
|
+
// Test interface for testing
|
|
13
|
+
interface TestBook extends BaseRecord<'book', RecordId<TestBook>> {
|
|
14
|
+
title: string
|
|
15
|
+
author: string
|
|
16
|
+
pages: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Helper functions to create test records
|
|
20
|
+
function createBook(id: string, title: string, author: string, pages: number = 100): TestBook {
|
|
21
|
+
return {
|
|
22
|
+
id: id as RecordId<TestBook>,
|
|
23
|
+
typeName: 'book',
|
|
24
|
+
title,
|
|
25
|
+
author,
|
|
26
|
+
pages,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Helper to create a diff
|
|
31
|
+
function createDiff<R extends TestBook>(
|
|
32
|
+
added: Record<string, R> = {},
|
|
33
|
+
updated: Record<string, [R, R]> = {},
|
|
34
|
+
removed: Record<string, R> = {}
|
|
35
|
+
): RecordsDiff<R> {
|
|
36
|
+
return {
|
|
37
|
+
added: added as any,
|
|
38
|
+
updated: updated as any,
|
|
39
|
+
removed: removed as any,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('isRecordsDiffEmpty', () => {
|
|
44
|
+
it('should return true for empty diffs and false for non-empty diffs', () => {
|
|
45
|
+
const emptyDiff = createEmptyRecordsDiff<TestBook>()
|
|
46
|
+
expect(isRecordsDiffEmpty(emptyDiff)).toBe(true)
|
|
47
|
+
|
|
48
|
+
const addedDiff = createDiff<TestBook>({ 'book:1': createBook('book:1', 'Test', 'Author') })
|
|
49
|
+
expect(isRecordsDiffEmpty(addedDiff)).toBe(false)
|
|
50
|
+
|
|
51
|
+
const oldBook = createBook('book:1', 'Old Title', 'Author')
|
|
52
|
+
const newBook = createBook('book:1', 'New Title', 'Author')
|
|
53
|
+
const updatedDiff = createDiff<TestBook>({}, { 'book:1': [oldBook, newBook] })
|
|
54
|
+
expect(isRecordsDiffEmpty(updatedDiff)).toBe(false)
|
|
55
|
+
|
|
56
|
+
const removedDiff = createDiff<TestBook>(
|
|
57
|
+
{},
|
|
58
|
+
{},
|
|
59
|
+
{ 'book:1': createBook('book:1', 'Deleted', 'Author') }
|
|
60
|
+
)
|
|
61
|
+
expect(isRecordsDiffEmpty(removedDiff)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('reverseRecordsDiff', () => {
|
|
66
|
+
it('should reverse all operation types correctly', () => {
|
|
67
|
+
const addedBook = createBook('book:1', 'Added Book', 'Author')
|
|
68
|
+
const oldUpdatedBook = createBook('book:2', 'Old Title', 'Author')
|
|
69
|
+
const newUpdatedBook = createBook('book:2', 'New Title', 'Author')
|
|
70
|
+
const removedBook = createBook('book:3', 'Removed Book', 'Author')
|
|
71
|
+
|
|
72
|
+
const originalDiff = createDiff<TestBook>(
|
|
73
|
+
{ 'book:1': addedBook },
|
|
74
|
+
{ 'book:2': [oldUpdatedBook, newUpdatedBook] },
|
|
75
|
+
{ 'book:3': removedBook }
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const reversedDiff = reverseRecordsDiff(originalDiff)
|
|
79
|
+
|
|
80
|
+
// Added becomes removed
|
|
81
|
+
expect(reversedDiff.removed['book:1']).toEqual(addedBook)
|
|
82
|
+
// Removed becomes added
|
|
83
|
+
expect(reversedDiff.added['book:3']).toEqual(removedBook)
|
|
84
|
+
// Updated swaps from/to
|
|
85
|
+
expect(reversedDiff.updated['book:2']).toEqual([newUpdatedBook, oldUpdatedBook])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('squashRecordDiffs', () => {
|
|
90
|
+
it('should handle core diff squashing operations', () => {
|
|
91
|
+
// Add then update becomes single add with final state
|
|
92
|
+
const initialBook = createBook('book:1', 'Initial Title', 'Author')
|
|
93
|
+
const updatedBook = createBook('book:1', 'Updated Title', 'Author')
|
|
94
|
+
|
|
95
|
+
const diff1 = createDiff<TestBook>({ 'book:1': initialBook })
|
|
96
|
+
const diff2 = createDiff<TestBook>({}, { 'book:1': [initialBook, updatedBook] })
|
|
97
|
+
const result1 = squashRecordDiffs([diff1, diff2])
|
|
98
|
+
expect(result1).toEqual(createDiff<TestBook>({ 'book:1': updatedBook }))
|
|
99
|
+
|
|
100
|
+
// Add then remove cancels out
|
|
101
|
+
const book = createBook('book:1', 'Test Book', 'Author')
|
|
102
|
+
const diff3 = createDiff<TestBook>({ 'book:1': book })
|
|
103
|
+
const diff4 = createDiff<TestBook>({}, {}, { 'book:1': book })
|
|
104
|
+
const result2 = squashRecordDiffs([diff3, diff4])
|
|
105
|
+
expect(result2).toEqual(createEmptyRecordsDiff<TestBook>())
|
|
106
|
+
|
|
107
|
+
// Remove then add becomes update
|
|
108
|
+
const originalBook = createBook('book:1', 'Original', 'Author')
|
|
109
|
+
const newBook = createBook('book:1', 'New', 'Author')
|
|
110
|
+
const diff5 = createDiff<TestBook>({}, {}, { 'book:1': originalBook })
|
|
111
|
+
const diff6 = createDiff<TestBook>({ 'book:1': newBook })
|
|
112
|
+
const result3 = squashRecordDiffs([diff5, diff6])
|
|
113
|
+
expect(result3).toEqual(createDiff<TestBook>({}, { 'book:1': [originalBook, newBook] }))
|
|
114
|
+
|
|
115
|
+
// Chain updates together
|
|
116
|
+
const v1 = createBook('book:1', 'Version 1', 'Author')
|
|
117
|
+
const v2 = createBook('book:1', 'Version 2', 'Author')
|
|
118
|
+
const v3 = createBook('book:1', 'Version 3', 'Author')
|
|
119
|
+
const diff7 = createDiff<TestBook>({}, { 'book:1': [v1, v2] })
|
|
120
|
+
const diff8 = createDiff<TestBook>({}, { 'book:1': [v2, v3] })
|
|
121
|
+
const result4 = squashRecordDiffs([diff7, diff8])
|
|
122
|
+
expect(result4).toEqual(createDiff<TestBook>({}, { 'book:1': [v1, v3] }))
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('squashRecordDiffsMutable', () => {
|
|
127
|
+
it('should maintain consistency with squashRecordDiffs', () => {
|
|
128
|
+
const book1 = createBook('book:1', 'Book 1', 'Author')
|
|
129
|
+
const book2Old = createBook('book:2', 'Book 2 Old', 'Author')
|
|
130
|
+
const book2New = createBook('book:2', 'Book 2 New', 'Author')
|
|
131
|
+
|
|
132
|
+
const diff1 = createDiff<TestBook>({ 'book:1': book1 })
|
|
133
|
+
const diff2 = createDiff<TestBook>({}, { 'book:2': [book2Old, book2New] })
|
|
134
|
+
|
|
135
|
+
// Test immutable version
|
|
136
|
+
const immutableResult = squashRecordDiffs([diff1, diff2])
|
|
137
|
+
|
|
138
|
+
// Test mutable version
|
|
139
|
+
const mutableTarget = createEmptyRecordsDiff<TestBook>()
|
|
140
|
+
squashRecordDiffsMutable(mutableTarget, [diff1, diff2])
|
|
141
|
+
|
|
142
|
+
expect(immutableResult).toEqual(mutableTarget)
|
|
143
|
+
})
|
|
144
|
+
})
|
package/src/lib/RecordsDiff.ts
CHANGED
|
@@ -2,22 +2,81 @@ import { objectMapEntries } from '@tldraw/utils'
|
|
|
2
2
|
import { IdOf, UnknownRecord } from './BaseRecord'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* A diff describing the changes to
|
|
5
|
+
* A diff describing the changes to records, containing collections of records that were added,
|
|
6
|
+
* updated, or removed. This is the fundamental data structure used throughout the store system
|
|
7
|
+
* to track and communicate changes.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const diff: RecordsDiff<Book> = {
|
|
12
|
+
* added: {
|
|
13
|
+
* 'book:1': { id: 'book:1', typeName: 'book', title: 'New Book' }
|
|
14
|
+
* },
|
|
15
|
+
* updated: {
|
|
16
|
+
* 'book:2': [
|
|
17
|
+
* { id: 'book:2', typeName: 'book', title: 'Old Title' }, // from
|
|
18
|
+
* { id: 'book:2', typeName: 'book', title: 'New Title' } // to
|
|
19
|
+
* ]
|
|
20
|
+
* },
|
|
21
|
+
* removed: {
|
|
22
|
+
* 'book:3': { id: 'book:3', typeName: 'book', title: 'Deleted Book' }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
6
26
|
*
|
|
7
27
|
* @public
|
|
8
28
|
*/
|
|
9
29
|
export interface RecordsDiff<R extends UnknownRecord> {
|
|
30
|
+
/** Records that were created, keyed by their ID */
|
|
10
31
|
added: Record<IdOf<R>, R>
|
|
32
|
+
/** Records that were modified, keyed by their ID. Each entry contains [from, to] tuple */
|
|
11
33
|
updated: Record<IdOf<R>, [from: R, to: R]>
|
|
34
|
+
/** Records that were deleted, keyed by their ID */
|
|
12
35
|
removed: Record<IdOf<R>, R>
|
|
13
36
|
}
|
|
14
37
|
|
|
15
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Creates an empty RecordsDiff with no added, updated, or removed records.
|
|
40
|
+
* This is useful as a starting point when building diffs programmatically.
|
|
41
|
+
*
|
|
42
|
+
* @returns An empty RecordsDiff with all collections initialized to empty objects
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const emptyDiff = createEmptyRecordsDiff<Book>()
|
|
46
|
+
* // Result: { added: {}, updated: {}, removed: {} }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
16
51
|
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R> {
|
|
17
52
|
return { added: {}, updated: {}, removed: {} } as RecordsDiff<R>
|
|
18
53
|
}
|
|
19
54
|
|
|
20
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Creates the inverse of a RecordsDiff, effectively reversing all changes.
|
|
57
|
+
* Added records become removed, removed records become added, and updated records
|
|
58
|
+
* have their from/to values swapped. This is useful for implementing undo operations.
|
|
59
|
+
*
|
|
60
|
+
* @param diff - The diff to reverse
|
|
61
|
+
* @returns A new RecordsDiff that represents the inverse of the input diff
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const originalDiff: RecordsDiff<Book> = {
|
|
65
|
+
* added: { 'book:1': newBook },
|
|
66
|
+
* updated: { 'book:2': [oldBook, updatedBook] },
|
|
67
|
+
* removed: { 'book:3': deletedBook }
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* const reversedDiff = reverseRecordsDiff(originalDiff)
|
|
71
|
+
* // Result: {
|
|
72
|
+
* // added: { 'book:3': deletedBook },
|
|
73
|
+
* // updated: { 'book:2': [updatedBook, oldBook] },
|
|
74
|
+
* // removed: { 'book:1': newBook }
|
|
75
|
+
* // }
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @public
|
|
79
|
+
*/
|
|
21
80
|
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
|
22
81
|
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
|
|
23
82
|
for (const [from, to] of Object.values(diff.updated)) {
|
|
@@ -27,8 +86,25 @@ export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
|
|
27
86
|
}
|
|
28
87
|
|
|
29
88
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
89
|
+
* Checks whether a RecordsDiff contains any changes. A diff is considered empty
|
|
90
|
+
* if it has no added, updated, or removed records.
|
|
91
|
+
*
|
|
92
|
+
* @param diff - The diff to check
|
|
93
|
+
* @returns True if the diff contains no changes, false otherwise
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* const emptyDiff = createEmptyRecordsDiff<Book>()
|
|
97
|
+
* console.log(isRecordsDiffEmpty(emptyDiff)) // true
|
|
98
|
+
*
|
|
99
|
+
* const nonEmptyDiff: RecordsDiff<Book> = {
|
|
100
|
+
* added: { 'book:1': someBook },
|
|
101
|
+
* updated: {},
|
|
102
|
+
* removed: {}
|
|
103
|
+
* }
|
|
104
|
+
* console.log(isRecordsDiffEmpty(nonEmptyDiff)) // false
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* @public
|
|
32
108
|
*/
|
|
33
109
|
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
|
|
34
110
|
return (
|
|
@@ -39,11 +115,38 @@ export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>
|
|
|
39
115
|
}
|
|
40
116
|
|
|
41
117
|
/**
|
|
42
|
-
*
|
|
118
|
+
* Combines multiple RecordsDiff objects into a single consolidated diff.
|
|
119
|
+
* This function intelligently merges changes, handling cases where the same record
|
|
120
|
+
* is modified multiple times across different diffs. For example, if a record is
|
|
121
|
+
* added in one diff and then updated in another, the result will show it as added
|
|
122
|
+
* with the final state.
|
|
123
|
+
*
|
|
124
|
+
* @param diffs - An array of diffs to combine into a single diff
|
|
125
|
+
* @param options - Configuration options for the squashing operation
|
|
126
|
+
* - mutateFirstDiff - If true, modifies the first diff in place instead of creating a new one
|
|
127
|
+
* @returns A single diff that represents the cumulative effect of all input diffs
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* const diff1: RecordsDiff<Book> = {
|
|
131
|
+
* added: { 'book:1': { id: 'book:1', title: 'New Book' } },
|
|
132
|
+
* updated: {},
|
|
133
|
+
* removed: {}
|
|
134
|
+
* }
|
|
135
|
+
*
|
|
136
|
+
* const diff2: RecordsDiff<Book> = {
|
|
137
|
+
* added: {},
|
|
138
|
+
* updated: { 'book:1': [{ id: 'book:1', title: 'New Book' }, { id: 'book:1', title: 'Updated Title' }] },
|
|
139
|
+
* removed: {}
|
|
140
|
+
* }
|
|
141
|
+
*
|
|
142
|
+
* const squashed = squashRecordDiffs([diff1, diff2])
|
|
143
|
+
* // Result: {
|
|
144
|
+
* // added: { 'book:1': { id: 'book:1', title: 'Updated Title' } },
|
|
145
|
+
* // updated: {},
|
|
146
|
+
* // removed: {}
|
|
147
|
+
* // }
|
|
148
|
+
* ```
|
|
43
149
|
*
|
|
44
|
-
* @param diffs - An array of diffs to squash.
|
|
45
|
-
* @param options - An optional object with a `mutateFirstDiff` property. If `mutateFirstDiff` is true, the first diff in the array will be mutated in-place.
|
|
46
|
-
* @returns A single diff that represents the squashed diffs.
|
|
47
150
|
* @public
|
|
48
151
|
*/
|
|
49
152
|
export function squashRecordDiffs<T extends UnknownRecord>(
|
|
@@ -61,7 +164,39 @@ export function squashRecordDiffs<T extends UnknownRecord>(
|
|
|
61
164
|
}
|
|
62
165
|
|
|
63
166
|
/**
|
|
64
|
-
*
|
|
167
|
+
* Applies an array of diffs to a target diff by mutating the target in-place.
|
|
168
|
+
* This is the core implementation used by squashRecordDiffs. It handles complex
|
|
169
|
+
* scenarios where records move between added/updated/removed states across multiple diffs.
|
|
170
|
+
*
|
|
171
|
+
* The function processes each diff sequentially, applying the following logic:
|
|
172
|
+
* - Added records: If the record was previously removed, convert to an update; otherwise add it
|
|
173
|
+
* - Updated records: Chain updates together, preserving the original 'from' state
|
|
174
|
+
* - Removed records: If the record was added in this sequence, cancel both operations
|
|
175
|
+
*
|
|
176
|
+
* @param target - The diff to modify in-place (will be mutated)
|
|
177
|
+
* @param diffs - Array of diffs to apply to the target
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* const targetDiff: RecordsDiff<Book> = {
|
|
181
|
+
* added: {},
|
|
182
|
+
* updated: {},
|
|
183
|
+
* removed: { 'book:1': oldBook }
|
|
184
|
+
* }
|
|
185
|
+
*
|
|
186
|
+
* const newDiffs = [{
|
|
187
|
+
* added: { 'book:1': newBook },
|
|
188
|
+
* updated: {},
|
|
189
|
+
* removed: {}
|
|
190
|
+
* }]
|
|
191
|
+
*
|
|
192
|
+
* squashRecordDiffsMutable(targetDiff, newDiffs)
|
|
193
|
+
* // targetDiff is now: {
|
|
194
|
+
* // added: {},
|
|
195
|
+
* // updated: { 'book:1': [oldBook, newBook] },
|
|
196
|
+
* // removed: {}
|
|
197
|
+
* // }
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
65
200
|
* @internal
|
|
66
201
|
*/
|
|
67
202
|
export function squashRecordDiffsMutable<T extends UnknownRecord>(
|