@tldraw/store 5.4.0-next.ebc95cf01337 → 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 (53) hide show
  1. package/DOCS.md +50 -50
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/AtomMap.js +4 -3
  4. package/dist-cjs/lib/AtomMap.js.map +2 -2
  5. package/dist-cjs/lib/ImmutableMap.js +11 -8
  6. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  7. package/dist-cjs/lib/IncrementalSetConstructor.js +2 -0
  8. package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
  9. package/dist-cjs/lib/RecordsDiff.js +11 -1
  10. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  11. package/dist-cjs/lib/Store.js +6 -2
  12. package/dist-cjs/lib/Store.js.map +2 -2
  13. package/dist-cjs/lib/StoreQueries.js +1 -1
  14. package/dist-cjs/lib/StoreQueries.js.map +2 -2
  15. package/dist-cjs/lib/StoreSchema.js +5 -1
  16. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  17. package/dist-cjs/lib/migrate.js +6 -5
  18. package/dist-cjs/lib/migrate.js.map +2 -2
  19. package/dist-esm/index.mjs +1 -1
  20. package/dist-esm/lib/AtomMap.mjs +4 -3
  21. package/dist-esm/lib/AtomMap.mjs.map +2 -2
  22. package/dist-esm/lib/ImmutableMap.mjs +11 -8
  23. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  24. package/dist-esm/lib/IncrementalSetConstructor.mjs +2 -0
  25. package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
  26. package/dist-esm/lib/RecordsDiff.mjs +11 -1
  27. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  28. package/dist-esm/lib/Store.mjs +6 -2
  29. package/dist-esm/lib/Store.mjs.map +2 -2
  30. package/dist-esm/lib/StoreQueries.mjs +1 -1
  31. package/dist-esm/lib/StoreQueries.mjs.map +2 -2
  32. package/dist-esm/lib/StoreSchema.mjs +5 -1
  33. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  34. package/dist-esm/lib/migrate.mjs +6 -5
  35. package/dist-esm/lib/migrate.mjs.map +2 -2
  36. package/package.json +9 -3
  37. package/src/lib/AtomMap.test.ts +31 -0
  38. package/src/lib/AtomMap.ts +8 -3
  39. package/src/lib/ImmutableMap.test.ts +33 -0
  40. package/src/lib/ImmutableMap.ts +16 -8
  41. package/src/lib/IncrementalSetConstructor.test.ts +10 -0
  42. package/src/lib/IncrementalSetConstructor.ts +4 -0
  43. package/src/lib/RecordsDiff.test.ts +27 -0
  44. package/src/lib/RecordsDiff.ts +19 -1
  45. package/src/lib/Store.ts +9 -4
  46. package/src/lib/StoreListeners.test.ts +22 -0
  47. package/src/lib/StoreQueries.test.ts +14 -0
  48. package/src/lib/StoreQueries.ts +4 -1
  49. package/src/lib/StoreSchema.test.ts +45 -0
  50. package/src/lib/StoreSchema.ts +6 -1
  51. package/src/lib/StoreSideEffects.test.ts +31 -0
  52. package/src/lib/migrate.test.ts +15 -0
  53. package/src/lib/migrate.ts +9 -5
@@ -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
  }