@tldraw/store 5.2.0-next.ee0fa4d6244f → 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.
- package/DOCS.md +790 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ImmutableMap.js +0 -13
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +1 -1
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +26 -14
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +6 -5
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +1 -1
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js +1 -1
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ImmutableMap.mjs +0 -13
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +1 -1
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +26 -14
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +7 -6
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +1 -1
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs +1 -1
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/package.json +9 -5
- package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
- package/src/lib/AtomSet.test.ts +116 -0
- package/src/lib/BaseRecord.test.ts +10 -22
- package/src/lib/ImmutableMap.test.ts +114 -71
- package/src/lib/ImmutableMap.ts +1 -0
- package/src/lib/IncrementalSetConstructor.test.ts +76 -81
- package/src/lib/RecordType.test.ts +216 -0
- package/src/lib/RecordType.ts +1 -1
- package/src/lib/RecordsDiff.test.ts +112 -106
- package/src/lib/RecordsDiff.ts +43 -18
- package/src/lib/Store.test.ts +570 -630
- package/src/lib/Store.ts +9 -10
- package/src/lib/StoreListeners.test.ts +462 -0
- package/src/lib/StoreQueries.test.ts +586 -434
- package/src/lib/StoreSchema.test.ts +1012 -174
- package/src/lib/StoreSchema.ts +1 -1
- package/src/lib/StoreSideEffects.test.ts +546 -158
- package/src/lib/devFreeze.test.ts +94 -124
- package/src/lib/executeQuery.test.ts +77 -31
- package/src/lib/executeQuery.ts +3 -1
- package/src/lib/migrate.test.ts +273 -296
- package/src/lib/setUtils.test.ts +38 -79
- package/src/lib/test/createMigrations.test.ts +0 -75
- package/src/lib/test/dependsOn.test.ts +0 -166
- package/src/lib/test/getMigrationsSince.test.ts +0 -121
- package/src/lib/test/migrate.test.ts +0 -118
- package/src/lib/test/migratePersistedRecord.test.ts +0 -265
- package/src/lib/test/migrationCaching.test.ts +0 -209
- package/src/lib/test/recordStore.test.ts +0 -1567
- package/src/lib/test/recordStoreQueries.test.ts +0 -814
- package/src/lib/test/recordType.test.ts +0 -19
- package/src/lib/test/sortMigrations.test.ts +0 -83
- package/src/lib/test/upgradeSchema.test.ts +0 -80
- package/src/lib/test/validate.test.ts +0 -178
- package/src/lib/test/validateMigrations.test.ts +0 -165
package/src/lib/Store.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { AtomMap } from './AtomMap'
|
|
15
15
|
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
|
16
16
|
import { devFreeze } from './devFreeze'
|
|
17
|
-
import { RecordsDiff, squashRecordDiffs } from './RecordsDiff'
|
|
17
|
+
import { hasAnyKey, RecordsDiff, squashRecordDiffs } from './RecordsDiff'
|
|
18
18
|
import { RecordScope } from './RecordType'
|
|
19
19
|
import { StoreQueries } from './StoreQueries'
|
|
20
20
|
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
|
@@ -573,11 +573,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
573
573
|
updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)),
|
|
574
574
|
removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)),
|
|
575
575
|
}
|
|
576
|
-
if (
|
|
577
|
-
Object.keys(result.added).length === 0 &&
|
|
578
|
-
Object.keys(result.updated).length === 0 &&
|
|
579
|
-
Object.keys(result.removed).length === 0
|
|
580
|
-
) {
|
|
576
|
+
if (!hasAnyKey(result.added) && !hasAnyKey(result.updated) && !hasAnyKey(result.removed)) {
|
|
581
577
|
return null
|
|
582
578
|
}
|
|
583
579
|
return result
|
|
@@ -656,7 +652,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
656
652
|
|
|
657
653
|
if (validated === initialValue) continue
|
|
658
654
|
|
|
659
|
-
record = devFreeze(
|
|
655
|
+
record = devFreeze(validated)
|
|
660
656
|
this.records.set(record.id, record)
|
|
661
657
|
|
|
662
658
|
didChange = true
|
|
@@ -888,7 +884,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
888
884
|
this.clear()
|
|
889
885
|
this.put(Object.values(migrationResult.value))
|
|
890
886
|
this.ensureStoreIsUsable()
|
|
891
|
-
})
|
|
887
|
+
}, false)
|
|
892
888
|
} finally {
|
|
893
889
|
this.sideEffects.setIsEnabled(prevSideEffectsEnabled)
|
|
894
890
|
}
|
|
@@ -1252,7 +1248,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|
|
1252
1248
|
|
|
1253
1249
|
this.pendingAfterEvents = new Map()
|
|
1254
1250
|
const prevSideEffectsEnabled = this.sideEffects.isEnabled()
|
|
1255
|
-
|
|
1251
|
+
// an operation may switch side effects off, but never on while they are disabled
|
|
1252
|
+
this.sideEffects.setIsEnabled(runCallbacks && prevSideEffectsEnabled)
|
|
1256
1253
|
this._isInAtomicOp = true
|
|
1257
1254
|
|
|
1258
1255
|
if (isMergingRemoteChanges) {
|
|
@@ -1328,7 +1325,9 @@ function squashHistoryEntries<T extends UnknownRecord>(
|
|
|
1328
1325
|
return devFreeze(
|
|
1329
1326
|
chunked.map((chunk) => ({
|
|
1330
1327
|
source: chunk[0].source,
|
|
1331
|
-
|
|
1328
|
+
// a single-entry chunk needs no squashing — skip the O(N) copy of its diff
|
|
1329
|
+
changes:
|
|
1330
|
+
chunk.length === 1 ? chunk[0].changes : squashRecordDiffs(chunk.map((e) => e.changes)),
|
|
1332
1331
|
}))
|
|
1333
1332
|
)
|
|
1334
1333
|
}
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { react, RESET_VALUE, transact } from '@tldraw/state'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { BaseRecord, RecordId } from './BaseRecord'
|
|
4
|
+
import { RecordsDiff, reverseRecordsDiff } from './RecordsDiff'
|
|
5
|
+
import { createRecordType } from './RecordType'
|
|
6
|
+
import { Store } from './Store'
|
|
7
|
+
import { StoreSchema } from './StoreSchema'
|
|
8
|
+
|
|
9
|
+
// Tests for SPEC.md §8 (history and listeners).
|
|
10
|
+
// Rule IDs like [H4] in test names refer to that document.
|
|
11
|
+
|
|
12
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
13
|
+
title: string
|
|
14
|
+
author: RecordId<Author>
|
|
15
|
+
numPages: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Book = createRecordType<Book>('book', {
|
|
19
|
+
validator: { validate: (book) => book as Book },
|
|
20
|
+
scope: 'document',
|
|
21
|
+
}).withDefaultProperties(() => ({ numPages: 100 }))
|
|
22
|
+
|
|
23
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
24
|
+
name: string
|
|
25
|
+
isPseudonym: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const Author = createRecordType<Author>('author', {
|
|
29
|
+
validator: { validate: (author) => author as Author },
|
|
30
|
+
scope: 'document',
|
|
31
|
+
}).withDefaultProperties(() => ({ isPseudonym: false }))
|
|
32
|
+
|
|
33
|
+
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
|
|
34
|
+
visitorName: string
|
|
35
|
+
lastActive: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Visit = createRecordType<Visit>('visit', {
|
|
39
|
+
validator: { validate: (visit) => visit as Visit },
|
|
40
|
+
scope: 'session',
|
|
41
|
+
ephemeralKeys: { visitorName: false, lastActive: true },
|
|
42
|
+
}).withDefaultProperties(() => ({ visitorName: 'Anonymous', lastActive: 0 }))
|
|
43
|
+
|
|
44
|
+
interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
|
|
45
|
+
x: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const Cursor = createRecordType<Cursor>('cursor', {
|
|
49
|
+
scope: 'presence',
|
|
50
|
+
}).withDefaultProperties(() => ({ x: 0 }))
|
|
51
|
+
|
|
52
|
+
type LibraryType = Book | Author | Visit | Cursor
|
|
53
|
+
|
|
54
|
+
let store: Store<LibraryType>
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
store = new Store({
|
|
57
|
+
props: {},
|
|
58
|
+
schema: StoreSchema.create<LibraryType>({
|
|
59
|
+
book: Book,
|
|
60
|
+
author: Author,
|
|
61
|
+
visit: Visit,
|
|
62
|
+
cursor: Cursor,
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const tolkein = () => Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
|
|
68
|
+
const hobbit = () =>
|
|
69
|
+
Book.create({
|
|
70
|
+
title: 'The Hobbit',
|
|
71
|
+
id: Book.createId('hobbit'),
|
|
72
|
+
author: Author.createId('tolkein'),
|
|
73
|
+
numPages: 300,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('the history atom (H)', () => {
|
|
77
|
+
it('[H1] increments by exactly one per committed change-set, carrying the diff', () => {
|
|
78
|
+
let lastDiff: RecordsDiff<LibraryType>[] | typeof RESET_VALUE | undefined
|
|
79
|
+
const stop = react('history', (lastReactedEpoch) => {
|
|
80
|
+
store.history.get()
|
|
81
|
+
lastDiff = store.history.getDiffSince(lastReactedEpoch)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const before = store.history.get()
|
|
85
|
+
const author = tolkein()
|
|
86
|
+
store.put([author])
|
|
87
|
+
|
|
88
|
+
expect(store.history.get()).toBe(before + 1)
|
|
89
|
+
expect(lastDiff).toEqual([{ added: { [author.id]: author }, updated: {}, removed: {} }])
|
|
90
|
+
|
|
91
|
+
// a transaction with several mutations commits as one change per put/remove call
|
|
92
|
+
transact(() => {
|
|
93
|
+
store.put([hobbit()])
|
|
94
|
+
store.remove([author.id])
|
|
95
|
+
})
|
|
96
|
+
expect(store.history.get()).toBe(before + 3)
|
|
97
|
+
stop()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('listeners (H)', () => {
|
|
102
|
+
it('[H2] listen returns a remover and notification is deferred to the next frame', async () => {
|
|
103
|
+
try {
|
|
104
|
+
// @ts-expect-error - test-only escape hatch
|
|
105
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
106
|
+
const listener = vi.fn()
|
|
107
|
+
const removeListener = store.listen(listener)
|
|
108
|
+
|
|
109
|
+
store.put([tolkein()])
|
|
110
|
+
// not called synchronously
|
|
111
|
+
expect(listener).toHaveBeenCalledTimes(0)
|
|
112
|
+
|
|
113
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
114
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
115
|
+
|
|
116
|
+
removeListener()
|
|
117
|
+
store.put([hobbit()])
|
|
118
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
119
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
120
|
+
} finally {
|
|
121
|
+
// @ts-expect-error - test-only escape hatch
|
|
122
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = false
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('[H3] a flush squashes adjacent same-source entries, preserving source boundaries', async () => {
|
|
127
|
+
try {
|
|
128
|
+
// @ts-expect-error - test-only escape hatch
|
|
129
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
130
|
+
const entries: Array<{ source: string; addedIds: string[] }> = []
|
|
131
|
+
store.listen((entry) => {
|
|
132
|
+
entries.push({ source: entry.source, addedIds: Object.keys(entry.changes.added) })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const author = tolkein()
|
|
136
|
+
const book = hobbit()
|
|
137
|
+
const remoteAuthor = Author.create({ name: 'Remote', id: Author.createId('remote') })
|
|
138
|
+
const lastAuthor = Author.create({ name: 'Last', id: Author.createId('last') })
|
|
139
|
+
|
|
140
|
+
store.put([author])
|
|
141
|
+
store.put([book])
|
|
142
|
+
store.mergeRemoteChanges(() => {
|
|
143
|
+
store.put([remoteAuthor])
|
|
144
|
+
})
|
|
145
|
+
store.put([lastAuthor])
|
|
146
|
+
|
|
147
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
148
|
+
|
|
149
|
+
// [user, user, remote, user] -> [user, remote, user]
|
|
150
|
+
expect(entries).toEqual([
|
|
151
|
+
{ source: 'user', addedIds: [author.id, book.id] },
|
|
152
|
+
{ source: 'remote', addedIds: [remoteAuthor.id] },
|
|
153
|
+
{ source: 'user', addedIds: [lastAuthor.id] },
|
|
154
|
+
])
|
|
155
|
+
} finally {
|
|
156
|
+
// @ts-expect-error - test-only escape hatch
|
|
157
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = false
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('[H3] changes within one transaction arrive as one squashed entry', async () => {
|
|
162
|
+
const listener = vi.fn()
|
|
163
|
+
store.listen(listener)
|
|
164
|
+
|
|
165
|
+
const author = tolkein()
|
|
166
|
+
transact(() => {
|
|
167
|
+
store.put([author])
|
|
168
|
+
store.update(author.id, (r) => ({ ...r, name: 'Jimmy Tolks' }))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
172
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
173
|
+
expect(listener.mock.calls[0][0]).toEqual({
|
|
174
|
+
source: 'user',
|
|
175
|
+
changes: {
|
|
176
|
+
added: { [author.id]: { ...author, name: 'Jimmy Tolks' } },
|
|
177
|
+
updated: {},
|
|
178
|
+
removed: {},
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('[H4] source filters drop entries from other sources', () => {
|
|
184
|
+
const userListener = vi.fn()
|
|
185
|
+
const remoteListener = vi.fn()
|
|
186
|
+
store.listen(userListener, { source: 'user' })
|
|
187
|
+
store.listen(remoteListener, { source: 'remote' })
|
|
188
|
+
|
|
189
|
+
store.put([tolkein()])
|
|
190
|
+
expect(userListener).toHaveBeenCalledTimes(1)
|
|
191
|
+
expect(remoteListener).toHaveBeenCalledTimes(0)
|
|
192
|
+
|
|
193
|
+
store.mergeRemoteChanges(() => {
|
|
194
|
+
store.put([hobbit()])
|
|
195
|
+
})
|
|
196
|
+
expect(userListener).toHaveBeenCalledTimes(1)
|
|
197
|
+
expect(remoteListener).toHaveBeenCalledTimes(1)
|
|
198
|
+
expect(remoteListener.mock.calls[0][0].source).toBe('remote')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('[H4] scope filters reduce each entry to records of that scope', () => {
|
|
202
|
+
const documentListener = vi.fn()
|
|
203
|
+
const sessionListener = vi.fn()
|
|
204
|
+
const presenceListener = vi.fn()
|
|
205
|
+
|
|
206
|
+
store.listen(documentListener, { scope: 'document' })
|
|
207
|
+
store.listen(sessionListener, { scope: 'session' })
|
|
208
|
+
store.listen(presenceListener, { scope: 'presence' })
|
|
209
|
+
|
|
210
|
+
const author = tolkein()
|
|
211
|
+
const visit = Visit.create({ visitorName: 'John Doe' })
|
|
212
|
+
const cursor = Cursor.create({ x: 1 })
|
|
213
|
+
|
|
214
|
+
store.put([author, visit, cursor])
|
|
215
|
+
|
|
216
|
+
expect(documentListener).toHaveBeenCalledTimes(1)
|
|
217
|
+
expect(Object.keys(documentListener.mock.calls[0][0].changes.added)).toEqual([author.id])
|
|
218
|
+
|
|
219
|
+
expect(sessionListener).toHaveBeenCalledTimes(1)
|
|
220
|
+
expect(Object.keys(sessionListener.mock.calls[0][0].changes.added)).toEqual([visit.id])
|
|
221
|
+
|
|
222
|
+
expect(presenceListener).toHaveBeenCalledTimes(1)
|
|
223
|
+
expect(Object.keys(presenceListener.mock.calls[0][0].changes.added)).toEqual([cursor.id])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('[H4] a listener is not called when its scope filter leaves nothing', () => {
|
|
227
|
+
const sessionListener = vi.fn()
|
|
228
|
+
store.listen(sessionListener, { scope: 'session' })
|
|
229
|
+
|
|
230
|
+
// document-only change
|
|
231
|
+
store.put([tolkein(), hobbit()])
|
|
232
|
+
expect(sessionListener).toHaveBeenCalledTimes(0)
|
|
233
|
+
|
|
234
|
+
// mixed change still arrives, filtered
|
|
235
|
+
store.put([Visit.create({ visitorName: 'Jimmy Beans' }), Author.create({ name: 'Other' })])
|
|
236
|
+
expect(sessionListener).toHaveBeenCalledTimes(1)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('[H5] a new listener never sees changes made before it subscribed', async () => {
|
|
240
|
+
try {
|
|
241
|
+
// @ts-expect-error - test-only escape hatch
|
|
242
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
|
243
|
+
store.put([tolkein()])
|
|
244
|
+
const firstListener = vi.fn()
|
|
245
|
+
store.listen(firstListener)
|
|
246
|
+
expect(firstListener).toHaveBeenCalledTimes(0)
|
|
247
|
+
|
|
248
|
+
store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])
|
|
249
|
+
|
|
250
|
+
expect(firstListener).toHaveBeenCalledTimes(0)
|
|
251
|
+
|
|
252
|
+
// attaching a second listener flushes pending history to the first
|
|
253
|
+
const secondListener = vi.fn()
|
|
254
|
+
store.listen(secondListener)
|
|
255
|
+
|
|
256
|
+
expect(firstListener).toHaveBeenCalledTimes(1)
|
|
257
|
+
expect(secondListener).toHaveBeenCalledTimes(0)
|
|
258
|
+
|
|
259
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
260
|
+
|
|
261
|
+
expect(firstListener).toHaveBeenCalledTimes(1)
|
|
262
|
+
expect(secondListener).toHaveBeenCalledTimes(0)
|
|
263
|
+
} finally {
|
|
264
|
+
// @ts-expect-error - test-only escape hatch
|
|
265
|
+
globalThis.__FORCE_RAF_IN_TESTS__ = false
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('[H6] accumulated history is discarded while no listeners are attached', () => {
|
|
270
|
+
store.put([tolkein()])
|
|
271
|
+
// H6 has no public observation point, so this reaches into private state
|
|
272
|
+
expect((store as any).historyAccumulator._history).toHaveLength(0)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('extracting and intercepting changes (H)', () => {
|
|
277
|
+
const authorId = Author.createId('tolkein')
|
|
278
|
+
const bookId = Book.createId('hobbit')
|
|
279
|
+
|
|
280
|
+
it('[H7] extractingChanges returns the squashed diff of exactly the changes in fn', () => {
|
|
281
|
+
const author = tolkein()
|
|
282
|
+
const book = hobbit()
|
|
283
|
+
|
|
284
|
+
expect(
|
|
285
|
+
store.extractingChanges(() => {
|
|
286
|
+
store.put([author])
|
|
287
|
+
store.put([book])
|
|
288
|
+
})
|
|
289
|
+
).toEqual({
|
|
290
|
+
added: { [author.id]: author, [book.id]: book },
|
|
291
|
+
updated: {},
|
|
292
|
+
removed: {},
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const renamed = { ...book, title: 'The Hobbit: There and Back Again' }
|
|
296
|
+
expect(
|
|
297
|
+
store.extractingChanges(() => {
|
|
298
|
+
store.remove([authorId])
|
|
299
|
+
store.update(bookId, () => renamed)
|
|
300
|
+
})
|
|
301
|
+
).toEqual({
|
|
302
|
+
added: {},
|
|
303
|
+
updated: { [book.id]: [book, renamed] },
|
|
304
|
+
removed: { [author.id]: author },
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('[H7] listeners still see changes made inside extractingChanges', () => {
|
|
309
|
+
const listener = vi.fn()
|
|
310
|
+
store.listen(listener)
|
|
311
|
+
|
|
312
|
+
store.extractingChanges(() => {
|
|
313
|
+
store.put([tolkein()])
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('[H8] addHistoryInterceptor sees every change-set synchronously, with its source', () => {
|
|
320
|
+
const entries: any[] = []
|
|
321
|
+
const interceptor = vi.fn((entry, source) => entries.push({ entry, source }))
|
|
322
|
+
const remove = store.addHistoryInterceptor(interceptor)
|
|
323
|
+
|
|
324
|
+
const author = tolkein()
|
|
325
|
+
store.put([author])
|
|
326
|
+
expect(interceptor).toHaveBeenCalledTimes(1)
|
|
327
|
+
expect(entries[0].source).toBe('user')
|
|
328
|
+
expect(entries[0].entry.changes.added).toEqual({ [author.id]: author })
|
|
329
|
+
|
|
330
|
+
store.mergeRemoteChanges(() => {
|
|
331
|
+
store.put([hobbit()])
|
|
332
|
+
})
|
|
333
|
+
expect(interceptor).toHaveBeenCalledTimes(2)
|
|
334
|
+
expect(entries[1].source).toBe('remote')
|
|
335
|
+
|
|
336
|
+
remove()
|
|
337
|
+
store.remove([author.id])
|
|
338
|
+
expect(interceptor).toHaveBeenCalledTimes(2)
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('applying diffs (H)', () => {
|
|
343
|
+
const authorId = Author.createId('tolkein')
|
|
344
|
+
const bookId = Book.createId('hobbit')
|
|
345
|
+
|
|
346
|
+
it('[H9] applyDiff puts added and updated records and removes removed ones', () => {
|
|
347
|
+
const author = tolkein()
|
|
348
|
+
store.applyDiff({
|
|
349
|
+
added: { [author.id]: author },
|
|
350
|
+
updated: {},
|
|
351
|
+
removed: {},
|
|
352
|
+
} as RecordsDiff<LibraryType>)
|
|
353
|
+
expect(store.get(authorId)).toEqual(author)
|
|
354
|
+
|
|
355
|
+
const renamed = { ...author, name: 'Jimmy Tolks' }
|
|
356
|
+
store.applyDiff({
|
|
357
|
+
added: {},
|
|
358
|
+
updated: { [author.id]: [author, renamed] },
|
|
359
|
+
removed: {},
|
|
360
|
+
} as RecordsDiff<LibraryType>)
|
|
361
|
+
expect(store.get(authorId)).toEqual(renamed)
|
|
362
|
+
|
|
363
|
+
store.applyDiff({
|
|
364
|
+
added: {},
|
|
365
|
+
updated: {},
|
|
366
|
+
removed: { [author.id]: renamed },
|
|
367
|
+
} as RecordsDiff<LibraryType>)
|
|
368
|
+
expect(store.has(authorId)).toBe(false)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('[H9] applying a diff and then its reverse restores the prior state', () => {
|
|
372
|
+
store.put([tolkein(), hobbit()])
|
|
373
|
+
|
|
374
|
+
const checkpoint1 = store.getStoreSnapshot()
|
|
375
|
+
|
|
376
|
+
const forwardsDiff = store.extractingChanges(() => {
|
|
377
|
+
store.remove([authorId])
|
|
378
|
+
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const checkpoint2 = store.getStoreSnapshot()
|
|
382
|
+
|
|
383
|
+
store.applyDiff(reverseRecordsDiff(forwardsDiff))
|
|
384
|
+
expect(store.getStoreSnapshot()).toEqual(checkpoint1)
|
|
385
|
+
|
|
386
|
+
store.applyDiff(forwardsDiff)
|
|
387
|
+
expect(store.getStoreSnapshot()).toEqual(checkpoint2)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('[H9] runCallbacks: false applies the diff without side effects', () => {
|
|
391
|
+
const afterCreate = vi.fn()
|
|
392
|
+
store.sideEffects.registerAfterCreateHandler('author', afterCreate)
|
|
393
|
+
|
|
394
|
+
const author = tolkein()
|
|
395
|
+
store.applyDiff(
|
|
396
|
+
{ added: { [author.id]: author }, updated: {}, removed: {} } as RecordsDiff<LibraryType>,
|
|
397
|
+
{ runCallbacks: false }
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
expect(store.get(authorId)).toEqual(author)
|
|
401
|
+
expect(afterCreate).not.toHaveBeenCalled()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('[H10] ignoreEphemeralKeys', () => {
|
|
405
|
+
const visitId = Visit.createId('jane')
|
|
406
|
+
const visit = () => Visit.create({ id: visitId, visitorName: 'Jane', lastActive: 100 })
|
|
407
|
+
|
|
408
|
+
it('drops updates that touch only ephemeral keys', () => {
|
|
409
|
+
store.put([visit()])
|
|
410
|
+
const listener = vi.fn()
|
|
411
|
+
store.listen(listener)
|
|
412
|
+
|
|
413
|
+
store.applyDiff(
|
|
414
|
+
{
|
|
415
|
+
added: {},
|
|
416
|
+
updated: { [visitId]: [visit(), { ...visit(), lastActive: 999 }] },
|
|
417
|
+
removed: {},
|
|
418
|
+
} as RecordsDiff<LibraryType>,
|
|
419
|
+
{ ignoreEphemeralKeys: true }
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
expect((store.get(visitId) as Visit).lastActive).toBe(100)
|
|
423
|
+
expect(listener).not.toHaveBeenCalled()
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('merges non-ephemeral changes onto the stored record, keeping ephemeral values', () => {
|
|
427
|
+
store.put([visit()])
|
|
428
|
+
|
|
429
|
+
store.applyDiff(
|
|
430
|
+
{
|
|
431
|
+
added: {},
|
|
432
|
+
updated: {
|
|
433
|
+
[visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
|
|
434
|
+
},
|
|
435
|
+
removed: {},
|
|
436
|
+
} as RecordsDiff<LibraryType>,
|
|
437
|
+
{ ignoreEphemeralKeys: true }
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
const result = store.get(visitId) as Visit
|
|
441
|
+
expect(result.visitorName).toBe('Janet')
|
|
442
|
+
expect(result.lastActive).toBe(100) // the stored ephemeral value is kept
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('applies updates for missing records, and added records, in full', () => {
|
|
446
|
+
store.applyDiff(
|
|
447
|
+
{
|
|
448
|
+
added: {},
|
|
449
|
+
updated: {
|
|
450
|
+
[visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
|
|
451
|
+
},
|
|
452
|
+
removed: {},
|
|
453
|
+
} as RecordsDiff<LibraryType>,
|
|
454
|
+
{ ignoreEphemeralKeys: true }
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
const result = store.get(visitId) as Visit
|
|
458
|
+
expect(result.visitorName).toBe('Janet')
|
|
459
|
+
expect(result.lastActive).toBe(999)
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
})
|