@tldraw/store 5.2.0-next.e2b8d10bf10e → 5.2.0

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 (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. package/src/lib/test/validateMigrations.test.ts +0 -165
@@ -9,136 +9,142 @@ import {
9
9
  squashRecordDiffsMutable,
10
10
  } from './RecordsDiff'
11
11
 
12
- // Test interface for testing
13
- interface TestBook extends BaseRecord<'book', RecordId<TestBook>> {
12
+ // Tests for SPEC.md §4 (RecordsDiff).
13
+ // Rule IDs like [D3] in test names refer to that document.
14
+
15
+ interface Book extends BaseRecord<'book', RecordId<Book>> {
14
16
  title: string
15
- author: string
16
- pages: number
17
17
  }
18
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
- }
19
+ function book(id: string, title: string): Book {
20
+ return { id: id as RecordId<Book>, typeName: 'book', title }
28
21
  }
29
22
 
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
- }
23
+ function diff(
24
+ added: Record<string, Book> = {},
25
+ updated: Record<string, [Book, Book]> = {},
26
+ removed: Record<string, Book> = {}
27
+ ): RecordsDiff<Book> {
28
+ return { added, updated, removed } as RecordsDiff<Book>
41
29
  }
42
30
 
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)
31
+ describe('empty diffs (D)', () => {
32
+ it('[D1] createEmptyRecordsDiff returns a diff with empty collections', () => {
33
+ expect(createEmptyRecordsDiff()).toEqual({ added: {}, updated: {}, removed: {} })
34
+ })
35
+
36
+ it('[D1] isRecordsDiffEmpty is true exactly when all three collections are empty', () => {
37
+ expect(isRecordsDiffEmpty(createEmptyRecordsDiff())).toBe(true)
38
+
39
+ expect(isRecordsDiffEmpty(diff({ 'book:1': book('book:1', 'a') }))).toBe(false)
40
+ expect(
41
+ isRecordsDiffEmpty(diff({}, { 'book:1': [book('book:1', 'a'), book('book:1', 'b')] }))
42
+ ).toBe(false)
43
+ expect(isRecordsDiffEmpty(diff({}, {}, { 'book:1': book('book:1', 'a') }))).toBe(false)
44
+ })
45
+ })
47
46
 
48
- const addedDiff = createDiff<TestBook>({ 'book:1': createBook('book:1', 'Test', 'Author') })
49
- expect(isRecordsDiffEmpty(addedDiff)).toBe(false)
47
+ describe('reversing diffs (D)', () => {
48
+ it('[D2] swaps added and removed and reverses updated pairs', () => {
49
+ const added = book('book:1', 'Added')
50
+ const from = book('book:2', 'Old')
51
+ const to = book('book:2', 'New')
52
+ const removed = book('book:3', 'Removed')
50
53
 
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)
54
+ const reversed = reverseRecordsDiff(
55
+ diff({ 'book:1': added }, { 'book:2': [from, to] }, { 'book:3': removed })
56
+ )
55
57
 
56
- const removedDiff = createDiff<TestBook>(
57
- {},
58
- {},
59
- { 'book:1': createBook('book:1', 'Deleted', 'Author') }
58
+ expect(reversed).toEqual(
59
+ diff({ 'book:3': removed }, { 'book:2': [to, from] }, { 'book:1': added })
60
60
  )
61
- expect(isRecordsDiffEmpty(removedDiff)).toBe(false)
62
61
  })
63
62
  })
64
63
 
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 }
64
+ describe('squashing diffs (D)', () => {
65
+ it('[D3] add then update becomes an add with the final value', () => {
66
+ const v1 = book('book:1', 'v1')
67
+ const v2 = book('book:1', 'v2')
68
+ expect(squashRecordDiffs([diff({ 'book:1': v1 }), diff({}, { 'book:1': [v1, v2] })])).toEqual(
69
+ diff({ 'book:1': v2 })
76
70
  )
71
+ })
77
72
 
78
- const reversedDiff = reverseRecordsDiff(originalDiff)
73
+ it('[D3] add then remove cancels out', () => {
74
+ const b = book('book:1', 'a')
75
+ expect(squashRecordDiffs([diff({ 'book:1': b }), diff({}, {}, { 'book:1': b })])).toEqual(
76
+ createEmptyRecordsDiff()
77
+ )
78
+ })
79
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])
80
+ it('[D3] update then update collapses from the original from to the final to', () => {
81
+ const v1 = book('book:1', 'v1')
82
+ const v2 = book('book:1', 'v2')
83
+ const v3 = book('book:1', 'v3')
84
+ expect(
85
+ squashRecordDiffs([diff({}, { 'book:1': [v1, v2] }), diff({}, { 'book:1': [v2, v3] })])
86
+ ).toEqual(diff({}, { 'book:1': [v1, v3] }))
86
87
  })
87
- })
88
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] }))
89
+ it('[D3] update then remove becomes a remove of the original from', () => {
90
+ const v1 = book('book:1', 'v1')
91
+ const v2 = book('book:1', 'v2')
92
+ expect(
93
+ squashRecordDiffs([diff({}, { 'book:1': [v1, v2] }), diff({}, {}, { 'book:1': v2 })])
94
+ ).toEqual(diff({}, {}, { 'book:1': v1 }))
123
95
  })
124
- })
125
96
 
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')
97
+ it('[D3] remove then add becomes an update', () => {
98
+ const original = book('book:1', 'Original')
99
+ const replacement = book('book:1', 'New')
100
+ expect(
101
+ squashRecordDiffs([diff({}, {}, { 'book:1': original }), diff({ 'book:1': replacement })])
102
+ ).toEqual(diff({}, { 'book:1': [original, replacement] }))
103
+ })
104
+
105
+ it('[D3] remove then add of the identical object cancels out', () => {
106
+ const same = book('book:1', 'Same')
107
+ expect(squashRecordDiffs([diff({}, {}, { 'book:1': same }), diff({ 'book:1': same })])).toEqual(
108
+ createEmptyRecordsDiff()
109
+ )
110
+ })
111
+
112
+ it('[D4] does not mutate its inputs by default', () => {
113
+ const v1 = book('book:1', 'v1')
114
+ const v2 = book('book:1', 'v2')
115
+ const first = diff({ 'book:1': v1 })
116
+ const second = diff({}, { 'book:1': [v1, v2] })
117
+
118
+ squashRecordDiffs([first, second])
119
+
120
+ expect(first).toEqual(diff({ 'book:1': v1 }))
121
+ expect(second).toEqual(diff({}, { 'book:1': [v1, v2] }))
122
+ })
123
+
124
+ it('[D4] mutateFirstDiff applies the result onto the first diff in place', () => {
125
+ const v1 = book('book:1', 'v1')
126
+ const v2 = book('book:1', 'v2')
127
+ const first = diff({ 'book:1': v1 })
128
+
129
+ const result = squashRecordDiffs([first, diff({}, { 'book:1': [v1, v2] })], {
130
+ mutateFirstDiff: true,
131
+ })
132
+
133
+ expect(result).toBe(first)
134
+ expect(first).toEqual(diff({ 'book:1': v2 }))
135
+ })
131
136
 
132
- const diff1 = createDiff<TestBook>({ 'book:1': book1 })
133
- const diff2 = createDiff<TestBook>({}, { 'book:2': [book2Old, book2New] })
137
+ it('[D4] squashRecordDiffsMutable agrees with squashRecordDiffs', () => {
138
+ const added = book('book:1', 'Book 1')
139
+ const from = book('book:2', 'Old')
140
+ const to = book('book:2', 'New')
134
141
 
135
- // Test immutable version
136
- const immutableResult = squashRecordDiffs([diff1, diff2])
142
+ const diffs = [diff({ 'book:1': added }), diff({}, { 'book:2': [from, to] })]
137
143
 
138
- // Test mutable version
139
- const mutableTarget = createEmptyRecordsDiff<TestBook>()
140
- squashRecordDiffsMutable(mutableTarget, [diff1, diff2])
144
+ const immutableResult = squashRecordDiffs(diffs)
145
+ const mutableTarget = createEmptyRecordsDiff<Book>()
146
+ squashRecordDiffsMutable(mutableTarget, diffs)
141
147
 
142
- expect(immutableResult).toEqual(mutableTarget)
148
+ expect(mutableTarget).toEqual(immutableResult)
143
149
  })
144
150
  })
@@ -1,4 +1,3 @@
1
- import { objectMapEntries } from '@tldraw/utils'
2
1
  import { IdOf, UnknownRecord } from './BaseRecord'
3
2
 
4
3
  /**
@@ -107,11 +106,16 @@ export function reverseRecordsDiff(diff: RecordsDiff<any>) {
107
106
  * @public
108
107
  */
109
108
  export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
110
- return (
111
- Object.keys(diff.added).length === 0 &&
112
- Object.keys(diff.updated).length === 0 &&
113
- Object.keys(diff.removed).length === 0
114
- )
109
+ return !hasAnyKey(diff.added) && !hasAnyKey(diff.updated) && !hasAnyKey(diff.removed)
110
+ }
111
+
112
+ // Cheaper than Object.keys().length, but note that for-in still pays an O(N)
113
+ // key-collection prologue on dictionary-mode objects (which diffs become once keys
114
+ // are deleted from them) — avoid calling this on large diffs in per-frame hot paths.
115
+ /** @internal */
116
+ export function hasAnyKey(obj: object) {
117
+ for (const _ in obj) return true
118
+ return false
115
119
  }
116
120
 
117
121
  /**
@@ -203,9 +207,22 @@ export function squashRecordDiffsMutable<T extends UnknownRecord>(
203
207
  target: RecordsDiff<T>,
204
208
  diffs: RecordsDiff<T>[]
205
209
  ): void {
210
+ // This runs on every history interceptor call — e.g. once per input tick while
211
+ // resizing N shapes, with N entries in diff.updated — so the updated loop must not
212
+ // allocate per entry. We use for-in instead of Object.entries, mutate the target's
213
+ // existing [from, to] tuples in place, and skip delete calls against collections we
214
+ // know are empty. In-place tuple mutation is safe because the target exclusively
215
+ // owns its updated tuples: they are always created here (never shared with a source
216
+ // diff), and sources are never mutated.
206
217
  for (const diff of diffs) {
207
- for (const [id, value] of objectMapEntries(diff.added)) {
208
- if (target.removed[id]) {
218
+ // target.removed can only lose entries before the removed loop below, so a
219
+ // stale `true` is harmless (extra no-op deletes).
220
+ const targetHasRemoved = hasAnyKey(target.removed)
221
+
222
+ for (const _id in diff.added) {
223
+ const id = _id as IdOf<T>
224
+ const value = diff.added[id]
225
+ if (targetHasRemoved && target.removed[id]) {
209
226
  const original = target.removed[id]
210
227
  delete target.removed[id]
211
228
  if (original !== value) {
@@ -216,24 +233,32 @@ export function squashRecordDiffsMutable<T extends UnknownRecord>(
216
233
  }
217
234
  }
218
235
 
219
- for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
220
- if (target.added[id]) {
236
+ // the added loop above may have inserted into target.added
237
+ const targetHasAdded = hasAnyKey(target.added)
238
+
239
+ for (const _id in diff.updated) {
240
+ const id = _id as IdOf<T>
241
+ const to = diff.updated[id][1]
242
+ if (targetHasAdded && target.added[id]) {
221
243
  target.added[id] = to
222
244
  delete target.updated[id]
223
- delete target.removed[id]
245
+ if (targetHasRemoved) delete target.removed[id]
224
246
  continue
225
247
  }
226
- if (target.updated[id]) {
227
- target.updated[id] = [target.updated[id][0], to]
228
- delete target.removed[id]
248
+ const existing = target.updated[id]
249
+ if (existing) {
250
+ existing[1] = to
251
+ if (targetHasRemoved) delete target.removed[id]
229
252
  continue
230
253
  }
231
254
 
232
- target.updated[id] = diff.updated[id]
233
- delete target.removed[id]
255
+ // copy the tuple so the target owns it and can mutate it in place later
256
+ target.updated[id] = [diff.updated[id][0], to]
257
+ if (targetHasRemoved) delete target.removed[id]
234
258
  }
235
259
 
236
- for (const [id, value] of objectMapEntries(diff.removed)) {
260
+ for (const _id in diff.removed) {
261
+ const id = _id as IdOf<T>
237
262
  // the same record was added in this diff sequence, just drop it
238
263
  if (target.added[id]) {
239
264
  delete target.added[id]
@@ -241,7 +266,7 @@ export function squashRecordDiffsMutable<T extends UnknownRecord>(
241
266
  target.removed[id] = target.updated[id][0]
242
267
  delete target.updated[id]
243
268
  } else {
244
- target.removed[id] = value
269
+ target.removed[id] = diff.removed[id]
245
270
  }
246
271
  }
247
272
  }