@tldraw/store 5.4.0-next.ef99ab47e5ce → 5.4.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 (55) hide show
  1. package/DOCS.md +50 -50
  2. package/dist-cjs/index.d.ts +38 -8
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/AtomMap.js +45 -3
  5. package/dist-cjs/lib/AtomMap.js.map +3 -3
  6. package/dist-cjs/lib/ImmutableMap.js +11 -8
  7. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  8. package/dist-cjs/lib/IncrementalSetConstructor.js +2 -0
  9. package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
  10. package/dist-cjs/lib/RecordsDiff.js +11 -1
  11. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  12. package/dist-cjs/lib/Store.js +6 -2
  13. package/dist-cjs/lib/Store.js.map +2 -2
  14. package/dist-cjs/lib/StoreQueries.js +1 -1
  15. package/dist-cjs/lib/StoreQueries.js.map +2 -2
  16. package/dist-cjs/lib/StoreSchema.js +5 -1
  17. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  18. package/dist-cjs/lib/migrate.js +6 -5
  19. package/dist-cjs/lib/migrate.js.map +2 -2
  20. package/dist-esm/index.d.mts +38 -8
  21. package/dist-esm/index.mjs +1 -1
  22. package/dist-esm/lib/AtomMap.mjs +45 -3
  23. package/dist-esm/lib/AtomMap.mjs.map +3 -3
  24. package/dist-esm/lib/ImmutableMap.mjs +11 -8
  25. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  26. package/dist-esm/lib/IncrementalSetConstructor.mjs +2 -0
  27. package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
  28. package/dist-esm/lib/RecordsDiff.mjs +11 -1
  29. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  30. package/dist-esm/lib/Store.mjs +6 -2
  31. package/dist-esm/lib/Store.mjs.map +2 -2
  32. package/dist-esm/lib/StoreQueries.mjs +1 -1
  33. package/dist-esm/lib/StoreQueries.mjs.map +2 -2
  34. package/dist-esm/lib/StoreSchema.mjs +5 -1
  35. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  36. package/dist-esm/lib/migrate.mjs +6 -5
  37. package/dist-esm/lib/migrate.mjs.map +2 -2
  38. package/package.json +9 -3
  39. package/src/lib/AtomMap.test.ts +69 -0
  40. package/src/lib/AtomMap.ts +52 -3
  41. package/src/lib/ImmutableMap.test.ts +33 -0
  42. package/src/lib/ImmutableMap.ts +16 -8
  43. package/src/lib/IncrementalSetConstructor.test.ts +10 -0
  44. package/src/lib/IncrementalSetConstructor.ts +4 -0
  45. package/src/lib/RecordsDiff.test.ts +27 -0
  46. package/src/lib/RecordsDiff.ts +19 -1
  47. package/src/lib/Store.ts +9 -4
  48. package/src/lib/StoreListeners.test.ts +22 -0
  49. package/src/lib/StoreQueries.test.ts +14 -0
  50. package/src/lib/StoreQueries.ts +4 -1
  51. package/src/lib/StoreSchema.test.ts +45 -0
  52. package/src/lib/StoreSchema.ts +6 -1
  53. package/src/lib/StoreSideEffects.test.ts +31 -0
  54. package/src/lib/migrate.test.ts +15 -0
  55. package/src/lib/migrate.ts +9 -5
@@ -74,7 +74,7 @@ function cachedHashString(string: string) {
74
74
  hashed = hashString(string)
75
75
  if (stringHashCacheCount === STRING_HASH_CACHE_SIZE) {
76
76
  stringHashCacheCount = 0
77
- stringHashCache = {}
77
+ stringHashCache = Object.create(null)
78
78
  }
79
79
  stringHashCache[string] = hashed
80
80
  stringHashCacheCount++
@@ -144,7 +144,9 @@ const symbolMap = Object.create(null)
144
144
 
145
145
  let _objHashUID = 0
146
146
 
147
- let stringHashCache: Record<string, number> = {}
147
+ // Null-prototype so that keys named after `Object.prototype` members ('constructor', 'toString',
148
+ // '__proto__', ...) miss the cache instead of returning the inherited member as their "hash".
149
+ let stringHashCache: Record<string, number> = Object.create(null)
148
150
  let stringHashCacheCount = 0
149
151
  const STRING_HASH_CACHE_SIZE = 24_000
150
152
 
@@ -176,6 +178,12 @@ function arrCopy<I>(arr: Array<I>, offset = 0): Array<I> {
176
178
  return arr.slice(offset)
177
179
  }
178
180
 
181
+ // Key equality is SameValueZero, as in upstream Immutable.js and native `Map`: `NaN` equals `NaN`
182
+ // and `0` equals `-0`.
183
+ function is(a: unknown, b: unknown) {
184
+ return a === b || (a !== a && b !== b)
185
+ }
186
+
179
187
  class OwnerID {}
180
188
 
181
189
  /**
@@ -470,7 +478,7 @@ class ArrayMapNode<K, V> {
470
478
  get(_shift: unknown, _keyHash: unknown, key: K, notSetValue?: V) {
471
479
  const entries = this.entries
472
480
  for (let ii = 0, len = entries.length; ii < len; ii++) {
473
- if (Object.is(key, entries[ii][0])) {
481
+ if (is(key, entries[ii][0])) {
474
482
  return entries[ii][1]
475
483
  }
476
484
  }
@@ -492,7 +500,7 @@ class ArrayMapNode<K, V> {
492
500
  let idx = 0
493
501
  const len = entries.length
494
502
  for (; idx < len; idx++) {
495
- if (Object.is(key, entries[idx][0])) {
503
+ if (is(key, entries[idx][0])) {
496
504
  break
497
505
  }
498
506
  }
@@ -710,7 +718,7 @@ class HashCollisionNode<K, V> {
710
718
  get(shift: number, keyHash: number, key: K, notSetValue?: V) {
711
719
  const entries = this.entries
712
720
  for (let ii = 0, len = entries.length; ii < len; ii++) {
713
- if (Object.is(key, entries[ii][0])) {
721
+ if (is(key, entries[ii][0])) {
714
722
  return entries[ii][1]
715
723
  }
716
724
  }
@@ -745,7 +753,7 @@ class HashCollisionNode<K, V> {
745
753
  let idx = 0
746
754
  const len = entries.length
747
755
  for (; idx < len; idx++) {
748
- if (Object.is(key, entries[idx][0])) {
756
+ if (is(key, entries[idx][0])) {
749
757
  break
750
758
  }
751
759
  }
@@ -796,7 +804,7 @@ class ValueNode<K, V> {
796
804
  ) {}
797
805
 
798
806
  get(shift: number, keyHash: number, key: K, notSetValue?: V) {
799
- return Object.is(key, this.entry[0]) ? this.entry[1] : notSetValue
807
+ return is(key, this.entry[0]) ? this.entry[1] : notSetValue
800
808
  }
801
809
 
802
810
  update(
@@ -809,7 +817,7 @@ class ValueNode<K, V> {
809
817
  didAlter?: Ref
810
818
  ) {
811
819
  const removed = value === NOT_SET
812
- const keyMatch = Object.is(key, this.entry[0])
820
+ const keyMatch = is(key, this.entry[0])
813
821
  if (keyMatch ? value === this.entry[1] : removed) {
814
822
  return this
815
823
  }
@@ -104,3 +104,13 @@ describe('IncrementalSetConstructor (ISC)', () => {
104
104
  })
105
105
  })
106
106
  })
107
+
108
+ describe('IncrementalSetConstructor: diff shape (ISC)', () => {
109
+ it('[ISC2] an add/remove round trip does not leave an empty set in the diff', () => {
110
+ const constructor = new IncrementalSetConstructor(new Set(['a']))
111
+ constructor.add('b')
112
+ constructor.remove('b')
113
+ constructor.remove('a')
114
+ expect(constructor.get()).toEqual({ value: new Set(), diff: { removed: new Set(['a']) } })
115
+ })
116
+ })
@@ -74,6 +74,10 @@ export class IncrementalSetConstructor<T> {
74
74
  if (numRemoved === 0 && numAdded === 0) {
75
75
  return undefined
76
76
  }
77
+ // An add/remove round trip leaves an empty set behind; drop it so that consumers can treat
78
+ // the presence of `added`/`removed` as "there are additions/removals".
79
+ if (numAdded === 0) delete this.diff!.added
80
+ if (numRemoved === 0) delete this.diff!.removed
77
81
  return { value: this.nextValue!, diff: this.diff! }
78
82
  }
79
83
 
@@ -148,3 +148,30 @@ describe('squashing diffs (D)', () => {
148
148
  expect(mutableTarget).toEqual(immutableResult)
149
149
  })
150
150
  })
151
+
152
+ describe('diff ownership (D)', () => {
153
+ it('[D2] reverseRecordsDiff does not share its collections with the input', () => {
154
+ const input = diff({ 'book:1': book('book:1', 'Added') })
155
+ const reversed = reverseRecordsDiff(input)
156
+ squashRecordDiffsMutable(reversed, [diff({ 'book:1': book('book:1', 'Older') })])
157
+ expect(input).toEqual(diff({ 'book:1': book('book:1', 'Added') }))
158
+ })
159
+
160
+ it('[D4] mutateFirstDiff with no diffs returns an empty diff', () => {
161
+ expect(squashRecordDiffs([], { mutateFirstDiff: true })).toEqual(createEmptyRecordsDiff())
162
+ })
163
+
164
+ it('[D4] mutateFirstDiff does not mutate the [from, to] tuples of the first diff in place', () => {
165
+ const v1 = book('book:1', 'v1')
166
+ const v2 = book('book:1', 'v2')
167
+ const v3 = book('book:1', 'v3')
168
+ const tuple: [Book, Book] = [v1, v2]
169
+ const first = diff({}, { 'book:1': tuple })
170
+ const result = squashRecordDiffs([first, diff({}, { 'book:1': [v2, v3] })], {
171
+ mutateFirstDiff: true,
172
+ })
173
+ expect(result).toBe(first)
174
+ expect(result.updated['book:1' as RecordId<Book>]).toEqual([v1, v3])
175
+ expect(tuple).toEqual([v1, v2])
176
+ })
177
+ })
@@ -78,7 +78,13 @@ export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R
78
78
  * @public
79
79
  */
80
80
  export function reverseRecordsDiff(diff: RecordsDiff<any>) {
81
- const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
81
+ // Copy the collections rather than aliasing them: callers squash into the reversed diff
82
+ // (`squashRecordDiffsMutable`), which would otherwise write through to the input.
83
+ const result: RecordsDiff<any> = {
84
+ added: { ...diff.removed },
85
+ removed: { ...diff.added },
86
+ updated: {},
87
+ }
82
88
  for (const [from, to] of objectMapValues(diff.updated)) {
83
89
  result.updated[from.id] = [to, from]
84
90
  }
@@ -162,6 +168,14 @@ export function squashRecordDiffs<T extends UnknownRecord>(
162
168
  ): RecordsDiff<T> {
163
169
  if (options?.mutateFirstDiff) {
164
170
  const result = diffs[0]
171
+ if (!result) return createEmptyRecordsDiff()
172
+ // The squash mutates the target's `[from, to]` tuples in place, which is only safe when the
173
+ // target owns them. The first diff's tuples may be shared with other holders, so copy them.
174
+ for (const _id in result.updated) {
175
+ const id = _id as IdOf<T>
176
+ const [from, to] = result.updated[id]
177
+ result.updated[id] = [from, to]
178
+ }
165
179
  squashRecordDiffsMutable(result, diffs, 1)
166
180
  return result
167
181
  }
@@ -181,6 +195,10 @@ export function squashRecordDiffs<T extends UnknownRecord>(
181
195
  * - Updated records: Chain updates together, preserving the original 'from' state
182
196
  * - Removed records: If the record was added in this sequence, cancel both operations
183
197
  *
198
+ * The target's `updated` tuples are mutated in place, so the target must own them: start from
199
+ * `createEmptyRecordsDiff()` (or a diff built by earlier squashes into it) rather than a diff whose
200
+ * tuples are shared with another holder.
201
+ *
184
202
  * @param target - The diff to modify in-place (will be mutated)
185
203
  * @param diffs - Array of diffs to apply to the target
186
204
  * @example
package/src/lib/Store.ts CHANGED
@@ -1079,6 +1079,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1079
1079
  if (!changed) changed = { ...existing } as R
1080
1080
  ;(changed as any)[key] = value
1081
1081
  }
1082
+ // a key the update removed (present before, absent in `to`) is a change too
1083
+ for (const key of Object.keys(existing)) {
1084
+ if (type.ephemeralKeySet.has(key) || Object.hasOwn(to, key)) continue
1085
+ if (!changed) changed = { ...existing } as R
1086
+ delete (changed as any)[key]
1087
+ }
1082
1088
  if (changed) toPut.push(changed)
1083
1089
  } else {
1084
1090
  toPut.push(to)
@@ -1204,11 +1210,10 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1204
1210
 
1205
1211
  if (!this.pendingAfterEvents) {
1206
1212
  this.sideEffects.handleOperationComplete(source)
1207
- } else {
1208
- // if the side effects triggered by a remote operation resulted in more effects,
1209
- // those extra effects should not be marked as originating remotely.
1210
- source = 'user'
1211
1213
  }
1214
+ // Whatever the after-handlers or the operation-complete handlers changed in response to
1215
+ // a remote operation is not itself remote: later rounds are attributed to 'user'.
1216
+ source = 'user'
1212
1217
  }
1213
1218
  }
1214
1219
  private _isInAtomicOp = false
@@ -460,3 +460,25 @@ describe('applying diffs (H)', () => {
460
460
  })
461
461
  })
462
462
  })
463
+
464
+ describe('applying diffs with ignoreEphemeralKeys (H)', () => {
465
+ it('[H10] ignoreEphemeralKeys also applies the removal of a non-ephemeral key', () => {
466
+ const visitId = Visit.createId('jane')
467
+ const visit = Visit.create({ id: visitId, visitorName: 'Jane', lastActive: 100 })
468
+ store.put([visit])
469
+
470
+ const { visitorName: _dropped, ...withoutName } = visit
471
+ store.applyDiff(
472
+ {
473
+ added: {},
474
+ updated: { [visitId]: [visit, withoutName as Visit] },
475
+ removed: {},
476
+ } as RecordsDiff<LibraryType>,
477
+ { ignoreEphemeralKeys: true }
478
+ )
479
+
480
+ const result = store.get(visitId) as Visit
481
+ expect('visitorName' in result).toBe(false)
482
+ expect(result.lastActive).toBe(100)
483
+ })
484
+ })
@@ -801,3 +801,17 @@ describe('integration with reactive state (QI, QQ)', () => {
801
801
  expect(tolkeinBookCount.get()).toBe(2)
802
802
  })
803
803
  })
804
+
805
+ describe('queries across rolled-back transactions (QH, QQ)', () => {
806
+ it('[QQ2] the query creator is called without arguments', () => {
807
+ const args: unknown[][] = []
808
+ const ids = store.query.ids('book', (...rest: unknown[]) => {
809
+ args.push(rest)
810
+ return {}
811
+ })
812
+ ids.get()
813
+ store.put([Book.create({ title: 'Another', authorId: authors.bradbury.id })])
814
+ ids.get()
815
+ for (const call of args) expect(call).toEqual([])
816
+ })
817
+ })
@@ -530,7 +530,10 @@ export class StoreQueries<R extends UnknownRecord> {
530
530
  return prevValue
531
531
  }
532
532
  }
533
- const cachedQuery = computed('ids_query:' + name, queryCreator, {
533
+ // Wrapped rather than passed straight through: a computed's derive function receives
534
+ // `(previousValue, lastComputedEpoch)`, which a queryCreator with optional parameters
535
+ // would otherwise misread.
536
+ const cachedQuery = computed('ids_query:' + name, () => queryCreator(), {
534
537
  isEqual,
535
538
  })
536
539
 
@@ -993,6 +993,51 @@ describe('migrateStorage (MA)', () => {
993
993
  expect(storage.setCalls).toEqual([])
994
994
  })
995
995
 
996
+ it('[MA7] removes non-document records without deleting during its own entries() iteration', () => {
997
+ // better-sqlite3 throws on any write while a statement iterator is still open, so a delete
998
+ // issued from inside `for (const [id] of storage.entries())` fails on that backend
999
+ const SessionRecordType = createRecordType<BaseRecord<'sess', RecordId<any>>>('sess', {
1000
+ scope: 'session',
1001
+ })
1002
+ // the cleanup only runs when there is something to migrate
1003
+ const migrations = [
1004
+ createMigrationSequence({
1005
+ sequenceId: 'foo',
1006
+ sequence: [{ id: 'foo/1', scope: 'record', up: () => {} }],
1007
+ }),
1008
+ ]
1009
+ const schema = StoreSchema.create(
1010
+ { test: TestRecordType, sess: SessionRecordType },
1011
+ { migrations }
1012
+ )
1013
+ const doc = makeTestRecord({ schemaVersion: 2, sequences: {} })
1014
+ const sess = SessionRecordType.create({})
1015
+ const storage = makeStorage<any>(
1016
+ [
1017
+ [doc.id, doc],
1018
+ [sess.id, sess],
1019
+ ],
1020
+ schema.serializeEarliestVersion()
1021
+ )
1022
+ let openIterators = 0
1023
+ storage.entries = function* () {
1024
+ openIterators++
1025
+ try {
1026
+ yield* Array.from(storage.map.entries())
1027
+ } finally {
1028
+ openIterators--
1029
+ }
1030
+ }
1031
+ storage.delete = (id) => {
1032
+ if (openIterators > 0) throw new Error('This database connection is busy executing a query')
1033
+ storage.map.delete(id)
1034
+ }
1035
+
1036
+ schema.migrateStorage(storage as any)
1037
+
1038
+ expect([...storage.map.keys()]).toEqual([doc.id])
1039
+ })
1040
+
996
1041
  it('[MA4] storage-scope migrations receive the storage and may read and write records', () => {
997
1042
  // exercised end-to-end via Store.loadStoreSnapshot in Store.test.ts; here we check the
998
1043
  // storage object passed to the migrator is the one we provided
@@ -626,11 +626,16 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
626
626
  }
627
627
  // Clean up by filtering out any non-document records.
628
628
  // This is mainly legacy support for extremely early days tldraw.
629
+ // Collect first: as above, deleting during a live iterator can throw on SQLite backends.
630
+ const idsToDelete: string[] = []
629
631
  for (const [id, state] of storage.entries()) {
630
632
  if (this.getType(state.typeName).scope !== 'document') {
631
- storage.delete(id)
633
+ idsToDelete.push(id)
632
634
  }
633
635
  }
636
+ for (const id of idsToDelete) {
637
+ storage.delete(id)
638
+ }
634
639
  }
635
640
 
636
641
  /**
@@ -626,3 +626,34 @@ describe('atomic operations (AO)', () => {
626
626
  ])
627
627
  })
628
628
  })
629
+
630
+ describe('remote attribution (AO)', () => {
631
+ it('[AO8] changes made by operationComplete handlers after a remote operation are attributed to user', () => {
632
+ store.put([book1, book2])
633
+ const log: string[] = []
634
+ store.addHistoryInterceptor((_entry, source) => log.push('history:' + source))
635
+ store.sideEffects.registerAfterChangeHandler('book', (_prev, _next, source) =>
636
+ log.push('afterChange:' + source)
637
+ )
638
+ let once = true
639
+ store.sideEffects.registerOperationCompleteHandler((source) => {
640
+ log.push('operationComplete:' + source)
641
+ if (once) {
642
+ once = false
643
+ store.put([{ ...book2, title: 'renamed' }])
644
+ }
645
+ })
646
+
647
+ store.mergeRemoteChanges(() => store.remove([book1Id]))
648
+
649
+ expect(log).toEqual([
650
+ 'history:remote',
651
+ 'operationComplete:remote',
652
+ 'history:user',
653
+ 'afterChange:user',
654
+ 'operationComplete:user',
655
+ // the integrity check after the merge is its own (empty) user operation
656
+ 'operationComplete:user',
657
+ ])
658
+ })
659
+ })
@@ -375,3 +375,18 @@ describe('sortMigrations (MS)', () => {
375
375
  ).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: bar/1]`)
376
376
  })
377
377
  })
378
+
379
+ describe('sortMigrations: redundant dependencies (MS)', () => {
380
+ const sort = (migrations: Migration[]) => sortMigrations(migrations).map((mig) => mig.id)
381
+
382
+ it('[MS2] an explicit dependency on the implicit predecessor is not a cycle', () => {
383
+ expect(sort([m('foo/2', { dependsOn: ['foo/1'] }), m('foo/1')])).toEqual(['foo/1', 'foo/2'])
384
+ })
385
+
386
+ it('[MS2] a dependency listed twice counts once', () => {
387
+ expect(sort([m('bar/1', { dependsOn: ['foo/1', 'foo/1'] }), m('foo/1')])).toEqual([
388
+ 'foo/1',
389
+ 'bar/1',
390
+ ])
391
+ })
392
+ })
@@ -360,11 +360,15 @@ export function sortMigrations(migrations: Migration[]): Migration[] {
360
360
  // Explicit dependencies
361
361
  if (m.dependsOn) {
362
362
  for (const depId of m.dependsOn) {
363
- if (byId.has(depId)) {
364
- dependents.get(depId)!.add(m.id)
365
- explicitDeps.get(m.id)!.add(depId)
366
- inDegree.set(m.id, inDegree.get(m.id)! + 1)
367
- }
363
+ if (!byId.has(depId)) continue
364
+ explicitDeps.get(m.id)!.add(depId)
365
+ // A dependency that is also the implicit predecessor, or that is listed twice, must
366
+ // only count once: the edge is only ever removed once, so a double-counted in-degree
367
+ // would leave the migration unprocessed and be reported as a circular dependency.
368
+ const dependentsOfDep = dependents.get(depId)!
369
+ if (dependentsOfDep.has(m.id)) continue
370
+ dependentsOfDep.add(m.id)
371
+ inDegree.set(m.id, inDegree.get(m.id)! + 1)
368
372
  }
369
373
  }
370
374
  }