@tldraw/store 5.4.0-next.ef99ab47e5ce → 5.4.1
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/DOCS.md +50 -50
- package/dist-cjs/index.d.ts +38 -8
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/AtomMap.js +45 -3
- package/dist-cjs/lib/AtomMap.js.map +3 -3
- package/dist-cjs/lib/ImmutableMap.js +11 -8
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/IncrementalSetConstructor.js +2 -0
- package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +11 -1
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +6 -2
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreQueries.js +1 -1
- package/dist-cjs/lib/StoreQueries.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +5 -1
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/migrate.js +6 -5
- package/dist-cjs/lib/migrate.js.map +2 -2
- package/dist-esm/index.d.mts +38 -8
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/AtomMap.mjs +45 -3
- package/dist-esm/lib/AtomMap.mjs.map +3 -3
- package/dist-esm/lib/ImmutableMap.mjs +11 -8
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/IncrementalSetConstructor.mjs +2 -0
- package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +11 -1
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +6 -2
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreQueries.mjs +1 -1
- package/dist-esm/lib/StoreQueries.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +5 -1
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/migrate.mjs +6 -5
- package/dist-esm/lib/migrate.mjs.map +2 -2
- package/package.json +9 -3
- package/src/lib/AtomMap.test.ts +69 -0
- package/src/lib/AtomMap.ts +52 -3
- package/src/lib/ImmutableMap.test.ts +33 -0
- package/src/lib/ImmutableMap.ts +16 -8
- package/src/lib/IncrementalSetConstructor.test.ts +10 -0
- package/src/lib/IncrementalSetConstructor.ts +4 -0
- package/src/lib/RecordsDiff.test.ts +27 -0
- package/src/lib/RecordsDiff.ts +19 -1
- package/src/lib/Store.ts +9 -4
- package/src/lib/StoreListeners.test.ts +22 -0
- package/src/lib/StoreQueries.test.ts +14 -0
- package/src/lib/StoreQueries.ts +4 -1
- package/src/lib/StoreSchema.test.ts +45 -0
- package/src/lib/StoreSchema.ts +6 -1
- package/src/lib/StoreSideEffects.test.ts +31 -0
- package/src/lib/migrate.test.ts +15 -0
- package/src/lib/migrate.ts +9 -5
package/src/lib/ImmutableMap.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
+
})
|
package/src/lib/RecordsDiff.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/src/lib/StoreQueries.ts
CHANGED
|
@@ -530,7 +530,10 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
530
530
|
return prevValue
|
|
531
531
|
}
|
|
532
532
|
}
|
|
533
|
-
|
|
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
|
package/src/lib/StoreSchema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/src/lib/migrate.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/migrate.ts
CHANGED
|
@@ -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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
}
|