@tldraw/store 4.1.0-next.1b89b40eff1c → 4.1.0-next.9f145d10c7d0

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 (72) hide show
  1. package/dist-cjs/index.d.ts +1884 -153
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/AtomMap.js +241 -1
  4. package/dist-cjs/lib/AtomMap.js.map +2 -2
  5. package/dist-cjs/lib/BaseRecord.js.map +2 -2
  6. package/dist-cjs/lib/ImmutableMap.js +141 -0
  7. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  8. package/dist-cjs/lib/IncrementalSetConstructor.js +45 -5
  9. package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
  10. package/dist-cjs/lib/RecordType.js +116 -21
  11. package/dist-cjs/lib/RecordType.js.map +2 -2
  12. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  13. package/dist-cjs/lib/Store.js +233 -39
  14. package/dist-cjs/lib/Store.js.map +2 -2
  15. package/dist-cjs/lib/StoreQueries.js +135 -22
  16. package/dist-cjs/lib/StoreQueries.js.map +2 -2
  17. package/dist-cjs/lib/StoreSchema.js +207 -2
  18. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  19. package/dist-cjs/lib/StoreSideEffects.js +102 -10
  20. package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
  21. package/dist-cjs/lib/executeQuery.js.map +2 -2
  22. package/dist-cjs/lib/migrate.js.map +2 -2
  23. package/dist-cjs/lib/setUtils.js.map +2 -2
  24. package/dist-esm/index.d.mts +1884 -153
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/AtomMap.mjs +241 -1
  27. package/dist-esm/lib/AtomMap.mjs.map +2 -2
  28. package/dist-esm/lib/BaseRecord.mjs.map +2 -2
  29. package/dist-esm/lib/ImmutableMap.mjs +141 -0
  30. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  31. package/dist-esm/lib/IncrementalSetConstructor.mjs +45 -5
  32. package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
  33. package/dist-esm/lib/RecordType.mjs +116 -21
  34. package/dist-esm/lib/RecordType.mjs.map +2 -2
  35. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  36. package/dist-esm/lib/Store.mjs +233 -39
  37. package/dist-esm/lib/Store.mjs.map +2 -2
  38. package/dist-esm/lib/StoreQueries.mjs +135 -22
  39. package/dist-esm/lib/StoreQueries.mjs.map +2 -2
  40. package/dist-esm/lib/StoreSchema.mjs +207 -2
  41. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  42. package/dist-esm/lib/StoreSideEffects.mjs +102 -10
  43. package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
  44. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  45. package/dist-esm/lib/migrate.mjs.map +2 -2
  46. package/dist-esm/lib/setUtils.mjs.map +2 -2
  47. package/package.json +3 -3
  48. package/src/lib/AtomMap.ts +241 -1
  49. package/src/lib/BaseRecord.test.ts +44 -0
  50. package/src/lib/BaseRecord.ts +118 -4
  51. package/src/lib/ImmutableMap.test.ts +103 -0
  52. package/src/lib/ImmutableMap.ts +212 -0
  53. package/src/lib/IncrementalSetConstructor.test.ts +111 -0
  54. package/src/lib/IncrementalSetConstructor.ts +63 -6
  55. package/src/lib/RecordType.ts +149 -25
  56. package/src/lib/RecordsDiff.test.ts +144 -0
  57. package/src/lib/RecordsDiff.ts +145 -10
  58. package/src/lib/Store.test.ts +827 -0
  59. package/src/lib/Store.ts +533 -67
  60. package/src/lib/StoreQueries.test.ts +627 -0
  61. package/src/lib/StoreQueries.ts +194 -27
  62. package/src/lib/StoreSchema.test.ts +226 -0
  63. package/src/lib/StoreSchema.ts +386 -8
  64. package/src/lib/StoreSideEffects.test.ts +239 -19
  65. package/src/lib/StoreSideEffects.ts +266 -19
  66. package/src/lib/devFreeze.test.ts +137 -0
  67. package/src/lib/executeQuery.test.ts +481 -0
  68. package/src/lib/executeQuery.ts +80 -2
  69. package/src/lib/migrate.test.ts +400 -0
  70. package/src/lib/migrate.ts +187 -14
  71. package/src/lib/setUtils.test.ts +105 -0
  72. package/src/lib/setUtils.ts +44 -4
@@ -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
- * Create a new record of this type.
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
- * @param properties - The properties of the record.
71
- * @returns The new record.
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
- * Clone a record of this type.
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 The cloned record.
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
- * Takes an id like `user:123` and returns the part after the colon `123`
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
- * Check whether a record is an instance of this record type.
214
+ * Type guard that checks whether a record belongs to this RecordType.
135
215
  *
136
- * @example
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
- * const result = recordType.isInstance(someRecord)
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 Whether the record is an instance of this record type.
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
- * Check whether an id is an id of this type.
236
+ * Type guard that checks whether an id string belongs to this RecordType.
151
237
  *
152
- * @example
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
- * const result = recordType.isIn('someId')
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 Whether the id is an id of this type.
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
- * Check that the passed in record passes the validations for this type. Returns its input
197
- * correctly typed if it does, but throws an error otherwise.
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
- * Create a record type.
318
+ * Creates a new RecordType with the specified configuration.
209
319
  *
210
- * @example
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
- * const Book = createRecordType<Book>('book')
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 of the type to create.
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
+ })
@@ -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 a record.
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
- /** @internal */
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
- /** @public */
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
- * Is a records diff empty?
31
- * @internal
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
- * Squash a collection of diffs into a single diff.
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
- * Apply the array `diffs` to the `target` diff, mutating it in-place.
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>(