@tanstack/db 0.0.7 → 0.0.8
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/dist/cjs/collection.cjs +441 -284
- package/dist/cjs/collection.cjs.map +1 -1
- package/dist/cjs/collection.d.cts +103 -30
- package/dist/cjs/proxy.cjs +2 -2
- package/dist/cjs/proxy.cjs.map +1 -1
- package/dist/cjs/query/compiled-query.cjs +23 -37
- package/dist/cjs/query/compiled-query.cjs.map +1 -1
- package/dist/cjs/query/compiled-query.d.cts +2 -2
- package/dist/cjs/query/order-by.cjs +41 -38
- package/dist/cjs/query/order-by.cjs.map +1 -1
- package/dist/cjs/query/schema.d.cts +3 -3
- package/dist/cjs/transactions.cjs +7 -6
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/transactions.d.cts +9 -9
- package/dist/cjs/types.d.cts +28 -22
- package/dist/esm/collection.d.ts +103 -30
- package/dist/esm/collection.js +442 -285
- package/dist/esm/collection.js.map +1 -1
- package/dist/esm/proxy.js +2 -2
- package/dist/esm/proxy.js.map +1 -1
- package/dist/esm/query/compiled-query.d.ts +2 -2
- package/dist/esm/query/compiled-query.js +23 -37
- package/dist/esm/query/compiled-query.js.map +1 -1
- package/dist/esm/query/order-by.js +41 -38
- package/dist/esm/query/order-by.js.map +1 -1
- package/dist/esm/query/schema.d.ts +3 -3
- package/dist/esm/transactions.d.ts +9 -9
- package/dist/esm/transactions.js +7 -6
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/types.d.ts +28 -22
- package/package.json +2 -2
- package/src/collection.ts +624 -372
- package/src/proxy.ts +2 -2
- package/src/query/compiled-query.ts +26 -37
- package/src/query/order-by.ts +69 -67
- package/src/query/schema.ts +3 -3
- package/src/transactions.ts +24 -22
- package/src/types.ts +44 -22
package/src/collection.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Store } from "@tanstack/store"
|
|
2
2
|
import { withArrayChangeTracking, withChangeTracking } from "./proxy"
|
|
3
3
|
import { Transaction, getActiveTransaction } from "./transactions"
|
|
4
4
|
import { SortedMap } from "./SortedMap"
|
|
5
5
|
import type {
|
|
6
|
+
ChangeListener,
|
|
6
7
|
ChangeMessage,
|
|
7
8
|
CollectionConfig,
|
|
8
9
|
Fn,
|
|
@@ -15,16 +16,16 @@ import type {
|
|
|
15
16
|
UtilsRecord,
|
|
16
17
|
} from "./types"
|
|
17
18
|
|
|
18
|
-
// Store collections in memory
|
|
19
|
-
export const collectionsStore = new
|
|
20
|
-
new Map<string, CollectionImpl<any>>()
|
|
21
|
-
)
|
|
19
|
+
// Store collections in memory
|
|
20
|
+
export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
|
|
22
21
|
|
|
23
22
|
// Map to track loading collections
|
|
24
|
-
|
|
25
|
-
const loadingCollections = new Map<
|
|
23
|
+
const loadingCollectionResolvers = new Map<
|
|
26
24
|
string,
|
|
27
|
-
|
|
25
|
+
{
|
|
26
|
+
promise: Promise<CollectionImpl<any, any>>
|
|
27
|
+
resolve: (value: CollectionImpl<any, any>) => void
|
|
28
|
+
}
|
|
28
29
|
>()
|
|
29
30
|
|
|
30
31
|
interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
@@ -39,8 +40,9 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
|
39
40
|
*/
|
|
40
41
|
export interface Collection<
|
|
41
42
|
T extends object = Record<string, unknown>,
|
|
43
|
+
TKey extends string | number = string | number,
|
|
42
44
|
TUtils extends UtilsRecord = {},
|
|
43
|
-
> extends CollectionImpl<T> {
|
|
45
|
+
> extends CollectionImpl<T, TKey> {
|
|
44
46
|
readonly utils: TUtils
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -48,15 +50,19 @@ export interface Collection<
|
|
|
48
50
|
* Creates a new Collection instance with the given configuration
|
|
49
51
|
*
|
|
50
52
|
* @template T - The type of items in the collection
|
|
53
|
+
* @template TKey - The type of the key for the collection
|
|
51
54
|
* @template TUtils - The utilities record type
|
|
52
55
|
* @param options - Collection options with optional utilities
|
|
53
56
|
* @returns A new Collection with utilities exposed both at top level and under .utils
|
|
54
57
|
*/
|
|
55
58
|
export function createCollection<
|
|
56
59
|
T extends object = Record<string, unknown>,
|
|
60
|
+
TKey extends string | number = string | number,
|
|
57
61
|
TUtils extends UtilsRecord = {},
|
|
58
|
-
>(
|
|
59
|
-
|
|
62
|
+
>(
|
|
63
|
+
options: CollectionConfig<T, TKey> & { utils?: TUtils }
|
|
64
|
+
): Collection<T, TKey, TUtils> {
|
|
65
|
+
const collection = new CollectionImpl<T, TKey>(options)
|
|
60
66
|
|
|
61
67
|
// Copy utils to both top level and .utils namespace
|
|
62
68
|
if (options.utils) {
|
|
@@ -65,7 +71,7 @@ export function createCollection<
|
|
|
65
71
|
collection.utils = {} as TUtils
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
return collection as Collection<T, TUtils>
|
|
74
|
+
return collection as Collection<T, TKey, TUtils>
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/**
|
|
@@ -94,56 +100,54 @@ export function createCollection<
|
|
|
94
100
|
* @param config - Configuration for the collection, including id and sync
|
|
95
101
|
* @returns Promise that resolves when the initial sync is finished
|
|
96
102
|
*/
|
|
97
|
-
export function preloadCollection<
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
export function preloadCollection<
|
|
104
|
+
T extends object = Record<string, unknown>,
|
|
105
|
+
TKey extends string | number = string | number,
|
|
106
|
+
>(config: CollectionConfig<T, TKey>): Promise<CollectionImpl<T, TKey>> {
|
|
100
107
|
if (!config.id) {
|
|
101
108
|
throw new Error(`The id property is required for preloadCollection`)
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
// If the collection is already fully loaded, return a resolved promise
|
|
105
112
|
if (
|
|
106
|
-
collectionsStore.
|
|
107
|
-
!
|
|
113
|
+
collectionsStore.has(config.id) &&
|
|
114
|
+
!loadingCollectionResolvers.has(config.id)
|
|
108
115
|
) {
|
|
109
116
|
return Promise.resolve(
|
|
110
|
-
collectionsStore.
|
|
117
|
+
collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
111
118
|
)
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
// If the collection is in the process of loading, return its promise
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
122
|
+
if (loadingCollectionResolvers.has(config.id)) {
|
|
123
|
+
return loadingCollectionResolvers.get(config.id)!.promise
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
// Create a new collection instance if it doesn't exist
|
|
120
|
-
if (!collectionsStore.
|
|
121
|
-
collectionsStore.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
config.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
getId: config.getId,
|
|
131
|
-
sync: config.sync,
|
|
132
|
-
schema: config.schema,
|
|
133
|
-
})
|
|
134
|
-
)
|
|
135
|
-
return next
|
|
136
|
-
})
|
|
127
|
+
if (!collectionsStore.has(config.id)) {
|
|
128
|
+
collectionsStore.set(
|
|
129
|
+
config.id,
|
|
130
|
+
createCollection<T, TKey>({
|
|
131
|
+
id: config.id,
|
|
132
|
+
getKey: config.getKey,
|
|
133
|
+
sync: config.sync,
|
|
134
|
+
schema: config.schema,
|
|
135
|
+
})
|
|
136
|
+
)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
const collection = collectionsStore.
|
|
139
|
+
const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
140
140
|
|
|
141
141
|
// Create a promise that will resolve after the first commit
|
|
142
|
-
let resolveFirstCommit: () => void
|
|
143
|
-
const firstCommitPromise = new Promise<CollectionImpl<T>>((resolve) => {
|
|
144
|
-
resolveFirstCommit =
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
let resolveFirstCommit: (value: CollectionImpl<T, TKey>) => void
|
|
143
|
+
const firstCommitPromise = new Promise<CollectionImpl<T, TKey>>((resolve) => {
|
|
144
|
+
resolveFirstCommit = resolve
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Store the loading promise first
|
|
148
|
+
loadingCollectionResolvers.set(config.id, {
|
|
149
|
+
promise: firstCommitPromise,
|
|
150
|
+
resolve: resolveFirstCommit!,
|
|
147
151
|
})
|
|
148
152
|
|
|
149
153
|
// Register a one-time listener for the first commit
|
|
@@ -151,18 +155,13 @@ export function preloadCollection<T extends object = Record<string, unknown>>(
|
|
|
151
155
|
if (!config.id) {
|
|
152
156
|
throw new Error(`The id property is required for preloadCollection`)
|
|
153
157
|
}
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
if (loadingCollectionResolvers.has(config.id)) {
|
|
159
|
+
const resolver = loadingCollectionResolvers.get(config.id)!
|
|
160
|
+
loadingCollectionResolvers.delete(config.id)
|
|
161
|
+
resolver.resolve(collection)
|
|
157
162
|
}
|
|
158
163
|
})
|
|
159
164
|
|
|
160
|
-
// Store the loading promise
|
|
161
|
-
loadingCollections.set(
|
|
162
|
-
config.id,
|
|
163
|
-
firstCommitPromise as Promise<CollectionImpl<Record<string, unknown>>>
|
|
164
|
-
)
|
|
165
|
-
|
|
166
165
|
return firstCommitPromise
|
|
167
166
|
}
|
|
168
167
|
|
|
@@ -195,22 +194,34 @@ export class SchemaValidationError extends Error {
|
|
|
195
194
|
}
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
export class CollectionImpl<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
export class CollectionImpl<
|
|
198
|
+
T extends object = Record<string, unknown>,
|
|
199
|
+
TKey extends string | number = string | number,
|
|
200
|
+
> {
|
|
201
|
+
public transactions: SortedMap<string, Transaction<any>>
|
|
202
|
+
|
|
203
|
+
// Core state - make public for testing
|
|
204
|
+
public syncedData = new Map<TKey, T>()
|
|
205
|
+
public syncedMetadata = new Map<TKey, unknown>()
|
|
206
|
+
|
|
207
|
+
// Optimistic state tracking - make public for testing
|
|
208
|
+
public derivedUpserts = new Map<TKey, T>()
|
|
209
|
+
public derivedDeletes = new Set<TKey>()
|
|
210
|
+
|
|
211
|
+
// Cached size for performance
|
|
212
|
+
private _size = 0
|
|
213
|
+
|
|
214
|
+
// Event system
|
|
215
|
+
private changeListeners = new Set<ChangeListener<T, TKey>>()
|
|
216
|
+
private changeKeyListeners = new Map<TKey, Set<ChangeListener<T, TKey>>>()
|
|
217
|
+
|
|
218
|
+
// Utilities namespace
|
|
219
|
+
// This is populated by createCollection
|
|
203
220
|
public utils: Record<string, Fn> = {}
|
|
204
|
-
|
|
205
|
-
public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
|
|
206
|
-
public derivedState: Derived<Map<string, T>>
|
|
207
|
-
public derivedArray: Derived<Array<T>>
|
|
208
|
-
public derivedChanges: Derived<Array<ChangeMessage<T>>>
|
|
209
|
-
public syncedData = new Store<Map<string, T>>(new Map())
|
|
210
|
-
public syncedMetadata = new Store(new Map<string, unknown>())
|
|
221
|
+
|
|
211
222
|
private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
|
|
212
|
-
private syncedKeys = new Set<
|
|
213
|
-
public config: CollectionConfig<T>
|
|
223
|
+
private syncedKeys = new Set<TKey>()
|
|
224
|
+
public config: CollectionConfig<T, TKey>
|
|
214
225
|
private hasReceivedFirstCommit = false
|
|
215
226
|
|
|
216
227
|
// Array to store one-time commit listeners
|
|
@@ -233,7 +244,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
233
244
|
* @param config - Configuration object for the collection
|
|
234
245
|
* @throws Error if sync config is missing
|
|
235
246
|
*/
|
|
236
|
-
constructor(config: CollectionConfig<T>) {
|
|
247
|
+
constructor(config: CollectionConfig<T, TKey>) {
|
|
237
248
|
// eslint-disable-next-line
|
|
238
249
|
if (!config) {
|
|
239
250
|
throw new Error(`Collection requires a config`)
|
|
@@ -249,156 +260,12 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
249
260
|
throw new Error(`Collection requires a sync config`)
|
|
250
261
|
}
|
|
251
262
|
|
|
252
|
-
this.transactions = new
|
|
253
|
-
|
|
254
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
255
|
-
)
|
|
263
|
+
this.transactions = new SortedMap<string, Transaction<any>>(
|
|
264
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
256
265
|
)
|
|
257
266
|
|
|
258
|
-
// Copies of live mutations are stored here and removed once the transaction completes.
|
|
259
|
-
this.optimisticOperations = new Derived({
|
|
260
|
-
fn: ({ currDepVals: [transactions] }) => {
|
|
261
|
-
const result = Array.from(transactions.values())
|
|
262
|
-
.map((transaction) => {
|
|
263
|
-
const isActive = ![`completed`, `failed`].includes(
|
|
264
|
-
transaction.state
|
|
265
|
-
)
|
|
266
|
-
return transaction.mutations
|
|
267
|
-
.filter((mutation) => mutation.collection === this)
|
|
268
|
-
.map((mutation) => {
|
|
269
|
-
const message: OptimisticChangeMessage<T> = {
|
|
270
|
-
type: mutation.type,
|
|
271
|
-
key: mutation.key,
|
|
272
|
-
value: mutation.modified as T,
|
|
273
|
-
isActive,
|
|
274
|
-
}
|
|
275
|
-
if (
|
|
276
|
-
mutation.metadata !== undefined &&
|
|
277
|
-
mutation.metadata !== null
|
|
278
|
-
) {
|
|
279
|
-
message.metadata = mutation.metadata as Record<
|
|
280
|
-
string,
|
|
281
|
-
unknown
|
|
282
|
-
>
|
|
283
|
-
}
|
|
284
|
-
return message
|
|
285
|
-
})
|
|
286
|
-
})
|
|
287
|
-
.flat()
|
|
288
|
-
|
|
289
|
-
return result
|
|
290
|
-
},
|
|
291
|
-
deps: [this.transactions],
|
|
292
|
-
})
|
|
293
|
-
this.optimisticOperations.mount()
|
|
294
|
-
|
|
295
|
-
// Combine together synced data & optimistic operations.
|
|
296
|
-
this.derivedState = new Derived({
|
|
297
|
-
fn: ({ currDepVals: [syncedData, operations] }) => {
|
|
298
|
-
const combined = new Map<string, T>(syncedData)
|
|
299
|
-
|
|
300
|
-
// Apply the optimistic operations on top of the synced state.
|
|
301
|
-
for (const operation of operations) {
|
|
302
|
-
if (operation.isActive) {
|
|
303
|
-
switch (operation.type) {
|
|
304
|
-
case `insert`:
|
|
305
|
-
combined.set(operation.key, operation.value)
|
|
306
|
-
break
|
|
307
|
-
case `update`:
|
|
308
|
-
combined.set(operation.key, operation.value)
|
|
309
|
-
break
|
|
310
|
-
case `delete`:
|
|
311
|
-
combined.delete(operation.key)
|
|
312
|
-
break
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return combined
|
|
318
|
-
},
|
|
319
|
-
deps: [this.syncedData, this.optimisticOperations],
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
// Create a derived array from the map to avoid recalculating it
|
|
323
|
-
this.derivedArray = new Derived({
|
|
324
|
-
fn: ({ currDepVals: [stateMap] }) => {
|
|
325
|
-
// Collections returned by a query that has an orderBy are annotated
|
|
326
|
-
// with the _orderByIndex field.
|
|
327
|
-
// This is used to sort the array when it's derived.
|
|
328
|
-
const array: Array<T & { _orderByIndex?: number }> = Array.from(
|
|
329
|
-
stateMap.values()
|
|
330
|
-
)
|
|
331
|
-
if (array[0] && `_orderByIndex` in array[0]) {
|
|
332
|
-
;(array as Array<T & { _orderByIndex: number }>).sort((a, b) => {
|
|
333
|
-
if (a._orderByIndex === b._orderByIndex) {
|
|
334
|
-
return 0
|
|
335
|
-
}
|
|
336
|
-
return a._orderByIndex < b._orderByIndex ? -1 : 1
|
|
337
|
-
})
|
|
338
|
-
}
|
|
339
|
-
return array
|
|
340
|
-
},
|
|
341
|
-
deps: [this.derivedState],
|
|
342
|
-
})
|
|
343
|
-
this.derivedArray.mount()
|
|
344
|
-
|
|
345
|
-
this.derivedChanges = new Derived({
|
|
346
|
-
fn: ({
|
|
347
|
-
currDepVals: [derivedState, optimisticOperations],
|
|
348
|
-
prevDepVals,
|
|
349
|
-
}) => {
|
|
350
|
-
const prevDerivedState = prevDepVals?.[0] ?? new Map<string, T>()
|
|
351
|
-
const prevOptimisticOperations = prevDepVals?.[1] ?? []
|
|
352
|
-
const changedKeys = new Set(this.syncedKeys)
|
|
353
|
-
optimisticOperations
|
|
354
|
-
.flat()
|
|
355
|
-
.filter((op) => op.isActive)
|
|
356
|
-
.forEach((op) => changedKeys.add(op.key))
|
|
357
|
-
prevOptimisticOperations.flat().forEach((op) => {
|
|
358
|
-
changedKeys.add(op.key)
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
if (changedKeys.size === 0) {
|
|
362
|
-
return []
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const changes: Array<ChangeMessage<T>> = []
|
|
366
|
-
for (const key of changedKeys) {
|
|
367
|
-
if (prevDerivedState.has(key) && !derivedState.has(key)) {
|
|
368
|
-
changes.push({
|
|
369
|
-
type: `delete`,
|
|
370
|
-
key,
|
|
371
|
-
value: prevDerivedState.get(key)!,
|
|
372
|
-
})
|
|
373
|
-
} else if (!prevDerivedState.has(key) && derivedState.has(key)) {
|
|
374
|
-
changes.push({ type: `insert`, key, value: derivedState.get(key)! })
|
|
375
|
-
} else if (prevDerivedState.has(key) && derivedState.has(key)) {
|
|
376
|
-
const value = derivedState.get(key)!
|
|
377
|
-
const previousValue = prevDerivedState.get(key)
|
|
378
|
-
if (value !== previousValue) {
|
|
379
|
-
// Comparing objects by reference as records are not mutated
|
|
380
|
-
changes.push({
|
|
381
|
-
type: `update`,
|
|
382
|
-
key,
|
|
383
|
-
value,
|
|
384
|
-
previousValue,
|
|
385
|
-
})
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
this.syncedKeys.clear()
|
|
391
|
-
|
|
392
|
-
return changes
|
|
393
|
-
},
|
|
394
|
-
deps: [this.derivedState, this.optimisticOperations],
|
|
395
|
-
})
|
|
396
|
-
this.derivedChanges.mount()
|
|
397
|
-
|
|
398
267
|
this.config = config
|
|
399
268
|
|
|
400
|
-
this.derivedState.mount()
|
|
401
|
-
|
|
402
269
|
// Start the sync process
|
|
403
270
|
config.sync.sync({
|
|
404
271
|
collection: this,
|
|
@@ -421,22 +288,18 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
421
288
|
`The pending sync transaction is already committed, you can't still write to it.`
|
|
422
289
|
)
|
|
423
290
|
}
|
|
424
|
-
const key = this.
|
|
425
|
-
this.config.getId(messageWithoutKey.value),
|
|
426
|
-
messageWithoutKey.value
|
|
427
|
-
)
|
|
291
|
+
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
428
292
|
|
|
429
|
-
// Check if an item with this
|
|
293
|
+
// Check if an item with this key already exists when inserting
|
|
430
294
|
if (messageWithoutKey.type === `insert`) {
|
|
431
295
|
if (
|
|
432
|
-
this.syncedData.
|
|
296
|
+
this.syncedData.has(key) &&
|
|
433
297
|
!pendingTransaction.operations.some(
|
|
434
298
|
(op) => op.key === key && op.type === `delete`
|
|
435
299
|
)
|
|
436
300
|
) {
|
|
437
|
-
const id = this.config.getId(messageWithoutKey.value)
|
|
438
301
|
throw new Error(
|
|
439
|
-
`Cannot insert document with
|
|
302
|
+
`Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
|
|
440
303
|
)
|
|
441
304
|
}
|
|
442
305
|
}
|
|
@@ -462,63 +325,350 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
462
325
|
}
|
|
463
326
|
|
|
464
327
|
pendingTransaction.committed = true
|
|
465
|
-
|
|
466
328
|
this.commitPendingTransactions()
|
|
467
329
|
},
|
|
468
330
|
})
|
|
469
331
|
}
|
|
470
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Recompute optimistic state from active transactions
|
|
335
|
+
*/
|
|
336
|
+
private recomputeOptimisticState(): void {
|
|
337
|
+
const previousState = new Map(this.derivedUpserts)
|
|
338
|
+
const previousDeletes = new Set(this.derivedDeletes)
|
|
339
|
+
|
|
340
|
+
// Clear current optimistic state
|
|
341
|
+
this.derivedUpserts.clear()
|
|
342
|
+
this.derivedDeletes.clear()
|
|
343
|
+
|
|
344
|
+
// Apply active transactions
|
|
345
|
+
const activeTransactions = Array.from(this.transactions.values())
|
|
346
|
+
for (const transaction of activeTransactions) {
|
|
347
|
+
if (![`completed`, `failed`].includes(transaction.state)) {
|
|
348
|
+
for (const mutation of transaction.mutations) {
|
|
349
|
+
if (mutation.collection === this) {
|
|
350
|
+
switch (mutation.type) {
|
|
351
|
+
case `insert`:
|
|
352
|
+
case `update`:
|
|
353
|
+
this.derivedUpserts.set(mutation.key, mutation.modified as T)
|
|
354
|
+
this.derivedDeletes.delete(mutation.key)
|
|
355
|
+
break
|
|
356
|
+
case `delete`:
|
|
357
|
+
this.derivedUpserts.delete(mutation.key)
|
|
358
|
+
this.derivedDeletes.add(mutation.key)
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Update cached size
|
|
367
|
+
this._size = this.calculateSize()
|
|
368
|
+
|
|
369
|
+
// Collect events for changes
|
|
370
|
+
const events: Array<ChangeMessage<T, TKey>> = []
|
|
371
|
+
this.collectOptimisticChanges(previousState, previousDeletes, events)
|
|
372
|
+
|
|
373
|
+
// Emit all events at once
|
|
374
|
+
this.emitEvents(events)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Calculate the current size based on synced data and optimistic changes
|
|
379
|
+
*/
|
|
380
|
+
private calculateSize(): number {
|
|
381
|
+
const syncedSize = this.syncedData.size
|
|
382
|
+
const deletesFromSynced = Array.from(this.derivedDeletes).filter(
|
|
383
|
+
(key) => this.syncedData.has(key) && !this.derivedUpserts.has(key)
|
|
384
|
+
).length
|
|
385
|
+
const upsertsNotInSynced = Array.from(this.derivedUpserts.keys()).filter(
|
|
386
|
+
(key) => !this.syncedData.has(key)
|
|
387
|
+
).length
|
|
388
|
+
|
|
389
|
+
return syncedSize - deletesFromSynced + upsertsNotInSynced
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Collect events for optimistic changes
|
|
394
|
+
*/
|
|
395
|
+
private collectOptimisticChanges(
|
|
396
|
+
previousUpserts: Map<TKey, T>,
|
|
397
|
+
previousDeletes: Set<TKey>,
|
|
398
|
+
events: Array<ChangeMessage<T, TKey>>
|
|
399
|
+
): void {
|
|
400
|
+
const allKeys = new Set([
|
|
401
|
+
...previousUpserts.keys(),
|
|
402
|
+
...this.derivedUpserts.keys(),
|
|
403
|
+
...previousDeletes,
|
|
404
|
+
...this.derivedDeletes,
|
|
405
|
+
])
|
|
406
|
+
|
|
407
|
+
for (const key of allKeys) {
|
|
408
|
+
const currentValue = this.get(key)
|
|
409
|
+
const previousValue = this.getPreviousValue(
|
|
410
|
+
key,
|
|
411
|
+
previousUpserts,
|
|
412
|
+
previousDeletes
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if (previousValue !== undefined && currentValue === undefined) {
|
|
416
|
+
events.push({ type: `delete`, key, value: previousValue })
|
|
417
|
+
} else if (previousValue === undefined && currentValue !== undefined) {
|
|
418
|
+
events.push({ type: `insert`, key, value: currentValue })
|
|
419
|
+
} else if (
|
|
420
|
+
previousValue !== undefined &&
|
|
421
|
+
currentValue !== undefined &&
|
|
422
|
+
previousValue !== currentValue
|
|
423
|
+
) {
|
|
424
|
+
events.push({
|
|
425
|
+
type: `update`,
|
|
426
|
+
key,
|
|
427
|
+
value: currentValue,
|
|
428
|
+
previousValue,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get the previous value for a key given previous optimistic state
|
|
436
|
+
*/
|
|
437
|
+
private getPreviousValue(
|
|
438
|
+
key: TKey,
|
|
439
|
+
previousUpserts: Map<TKey, T>,
|
|
440
|
+
previousDeletes: Set<TKey>
|
|
441
|
+
): T | undefined {
|
|
442
|
+
if (previousDeletes.has(key)) {
|
|
443
|
+
return undefined
|
|
444
|
+
}
|
|
445
|
+
if (previousUpserts.has(key)) {
|
|
446
|
+
return previousUpserts.get(key)
|
|
447
|
+
}
|
|
448
|
+
return this.syncedData.get(key)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Emit multiple events at once to all listeners
|
|
453
|
+
*/
|
|
454
|
+
private emitEvents(changes: Array<ChangeMessage<T, TKey>>): void {
|
|
455
|
+
if (changes.length > 0) {
|
|
456
|
+
// Emit to general listeners
|
|
457
|
+
for (const listener of this.changeListeners) {
|
|
458
|
+
listener(changes)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Emit to key-specific listeners
|
|
462
|
+
if (this.changeKeyListeners.size > 0) {
|
|
463
|
+
// Group changes by key, but only for keys that have listeners
|
|
464
|
+
const changesByKey = new Map<TKey, Array<ChangeMessage<T, TKey>>>()
|
|
465
|
+
for (const change of changes) {
|
|
466
|
+
if (this.changeKeyListeners.has(change.key)) {
|
|
467
|
+
if (!changesByKey.has(change.key)) {
|
|
468
|
+
changesByKey.set(change.key, [])
|
|
469
|
+
}
|
|
470
|
+
changesByKey.get(change.key)!.push(change)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Emit batched changes to each key's listeners
|
|
475
|
+
for (const [key, keyChanges] of changesByKey) {
|
|
476
|
+
const keyListeners = this.changeKeyListeners.get(key)!
|
|
477
|
+
for (const listener of keyListeners) {
|
|
478
|
+
listener(keyChanges)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get the current value for a key (virtual derived state)
|
|
487
|
+
*/
|
|
488
|
+
public get(key: TKey): T | undefined {
|
|
489
|
+
// Check if optimistically deleted
|
|
490
|
+
if (this.derivedDeletes.has(key)) {
|
|
491
|
+
return undefined
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Check optimistic upserts first
|
|
495
|
+
if (this.derivedUpserts.has(key)) {
|
|
496
|
+
return this.derivedUpserts.get(key)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Fall back to synced data
|
|
500
|
+
return this.syncedData.get(key)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Check if a key exists in the collection (virtual derived state)
|
|
505
|
+
*/
|
|
506
|
+
public has(key: TKey): boolean {
|
|
507
|
+
// Check if optimistically deleted
|
|
508
|
+
if (this.derivedDeletes.has(key)) {
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Check optimistic upserts first
|
|
513
|
+
if (this.derivedUpserts.has(key)) {
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Fall back to synced data
|
|
518
|
+
return this.syncedData.has(key)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Get the current size of the collection (cached)
|
|
523
|
+
*/
|
|
524
|
+
public get size(): number {
|
|
525
|
+
return this._size
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Get all keys (virtual derived state)
|
|
530
|
+
*/
|
|
531
|
+
public *keys(): IterableIterator<TKey> {
|
|
532
|
+
// Yield keys from synced data, skipping any that are deleted.
|
|
533
|
+
for (const key of this.syncedData.keys()) {
|
|
534
|
+
if (!this.derivedDeletes.has(key)) {
|
|
535
|
+
yield key
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Yield keys from upserts that were not already in synced data.
|
|
539
|
+
for (const key of this.derivedUpserts.keys()) {
|
|
540
|
+
if (!this.syncedData.has(key) && !this.derivedDeletes.has(key)) {
|
|
541
|
+
// The derivedDeletes check is technically redundant if inserts/updates always remove from deletes,
|
|
542
|
+
// but it's safer to keep it.
|
|
543
|
+
yield key
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Get all values (virtual derived state)
|
|
550
|
+
*/
|
|
551
|
+
public *values(): IterableIterator<T> {
|
|
552
|
+
for (const key of this.keys()) {
|
|
553
|
+
const value = this.get(key)
|
|
554
|
+
if (value !== undefined) {
|
|
555
|
+
yield value
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Get all entries (virtual derived state)
|
|
562
|
+
*/
|
|
563
|
+
public *entries(): IterableIterator<[TKey, T]> {
|
|
564
|
+
for (const key of this.keys()) {
|
|
565
|
+
const value = this.get(key)
|
|
566
|
+
if (value !== undefined) {
|
|
567
|
+
yield [key, value]
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
471
572
|
/**
|
|
472
573
|
* Attempts to commit pending synced transactions if there are no active transactions
|
|
473
574
|
* This method processes operations from pending transactions and applies them to the synced data
|
|
474
575
|
*/
|
|
475
576
|
commitPendingTransactions = () => {
|
|
476
577
|
if (
|
|
477
|
-
!Array.from(this.transactions.
|
|
578
|
+
!Array.from(this.transactions.values()).some(
|
|
478
579
|
({ state }) => state === `persisting`
|
|
479
580
|
)
|
|
480
581
|
) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
582
|
+
const changedKeys = new Set<TKey>()
|
|
583
|
+
const events: Array<ChangeMessage<T, TKey>> = []
|
|
584
|
+
|
|
585
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
586
|
+
for (const operation of transaction.operations) {
|
|
587
|
+
const key = operation.key as TKey
|
|
588
|
+
changedKeys.add(key)
|
|
589
|
+
this.syncedKeys.add(key)
|
|
590
|
+
|
|
591
|
+
// Update metadata
|
|
592
|
+
switch (operation.type) {
|
|
593
|
+
case `insert`:
|
|
594
|
+
this.syncedMetadata.set(key, operation.metadata)
|
|
595
|
+
break
|
|
596
|
+
case `update`:
|
|
597
|
+
this.syncedMetadata.set(
|
|
598
|
+
key,
|
|
599
|
+
Object.assign(
|
|
600
|
+
{},
|
|
601
|
+
this.syncedMetadata.get(key),
|
|
602
|
+
operation.metadata
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
break
|
|
606
|
+
case `delete`:
|
|
607
|
+
this.syncedMetadata.delete(key)
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Update synced data and collect events
|
|
612
|
+
const previousValue = this.syncedData.get(key)
|
|
613
|
+
|
|
614
|
+
switch (operation.type) {
|
|
615
|
+
case `insert`:
|
|
616
|
+
this.syncedData.set(key, operation.value)
|
|
617
|
+
if (
|
|
618
|
+
!this.derivedDeletes.has(key) &&
|
|
619
|
+
!this.derivedUpserts.has(key)
|
|
620
|
+
) {
|
|
621
|
+
events.push({
|
|
622
|
+
type: `insert`,
|
|
623
|
+
key,
|
|
624
|
+
value: operation.value,
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
break
|
|
628
|
+
case `update`: {
|
|
629
|
+
const updatedValue = Object.assign(
|
|
630
|
+
{},
|
|
631
|
+
this.syncedData.get(key),
|
|
632
|
+
operation.value
|
|
633
|
+
)
|
|
634
|
+
this.syncedData.set(key, updatedValue)
|
|
635
|
+
if (
|
|
636
|
+
!this.derivedDeletes.has(key) &&
|
|
637
|
+
!this.derivedUpserts.has(key)
|
|
638
|
+
) {
|
|
639
|
+
events.push({
|
|
640
|
+
type: `update`,
|
|
641
|
+
key,
|
|
642
|
+
value: updatedValue,
|
|
643
|
+
previousValue,
|
|
644
|
+
})
|
|
499
645
|
}
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
646
|
+
break
|
|
647
|
+
}
|
|
648
|
+
case `delete`:
|
|
649
|
+
this.syncedData.delete(key)
|
|
650
|
+
if (
|
|
651
|
+
!this.derivedDeletes.has(key) &&
|
|
652
|
+
!this.derivedUpserts.has(key)
|
|
653
|
+
) {
|
|
654
|
+
if (previousValue) {
|
|
655
|
+
events.push({
|
|
656
|
+
type: `delete`,
|
|
657
|
+
key,
|
|
658
|
+
value: previousValue,
|
|
511
659
|
})
|
|
512
|
-
|
|
513
|
-
case `delete`:
|
|
514
|
-
prevData.delete(operation.key)
|
|
515
|
-
break
|
|
660
|
+
}
|
|
516
661
|
}
|
|
517
|
-
|
|
518
|
-
})
|
|
662
|
+
break
|
|
519
663
|
}
|
|
520
664
|
}
|
|
521
|
-
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Update cached size after synced data changes
|
|
668
|
+
this._size = this.calculateSize()
|
|
669
|
+
|
|
670
|
+
// Emit all events at once
|
|
671
|
+
this.emitEvents(events)
|
|
522
672
|
|
|
523
673
|
this.pendingSyncedTransactions = []
|
|
524
674
|
|
|
@@ -543,33 +693,24 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
543
693
|
)
|
|
544
694
|
}
|
|
545
695
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
throw new Error(`id is undefined`)
|
|
549
|
-
}
|
|
550
|
-
if (typeof id === `string` && id.startsWith(`KEY::`)) {
|
|
551
|
-
return id
|
|
552
|
-
} else {
|
|
553
|
-
// if it's not a string, then it's some other
|
|
554
|
-
// primitive type and needs turned into a key.
|
|
555
|
-
return this.generateObjectKey(id, null)
|
|
556
|
-
}
|
|
696
|
+
public getKeyFromItem(item: T): TKey {
|
|
697
|
+
return this.config.getKey(item)
|
|
557
698
|
}
|
|
558
699
|
|
|
559
|
-
public
|
|
560
|
-
if (typeof
|
|
700
|
+
public generateGlobalKey(key: any, item: any): string {
|
|
701
|
+
if (typeof key === `undefined`) {
|
|
561
702
|
throw new Error(
|
|
562
|
-
`An object was created without a defined
|
|
703
|
+
`An object was created without a defined key: ${JSON.stringify(item)}`
|
|
563
704
|
)
|
|
564
705
|
}
|
|
565
706
|
|
|
566
|
-
return `KEY::${this.id}/${
|
|
707
|
+
return `KEY::${this.id}/${key}`
|
|
567
708
|
}
|
|
568
709
|
|
|
569
710
|
private validateData(
|
|
570
711
|
data: unknown,
|
|
571
712
|
type: `insert` | `update`,
|
|
572
|
-
key?:
|
|
713
|
+
key?: TKey
|
|
573
714
|
): T | never {
|
|
574
715
|
if (!this.config.schema) return data as T
|
|
575
716
|
|
|
@@ -578,7 +719,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
578
719
|
// For updates, we need to merge with the existing data before validation
|
|
579
720
|
if (type === `update` && key) {
|
|
580
721
|
// Get the existing data for this key
|
|
581
|
-
const existingData = this.
|
|
722
|
+
const existingData = this.get(key)
|
|
582
723
|
|
|
583
724
|
if (
|
|
584
725
|
existingData &&
|
|
@@ -587,7 +728,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
587
728
|
typeof existingData === `object`
|
|
588
729
|
) {
|
|
589
730
|
// Merge the update with the existing data
|
|
590
|
-
const mergedData = {
|
|
731
|
+
const mergedData = Object.assign({}, existingData, data)
|
|
591
732
|
|
|
592
733
|
// Validate the merged data
|
|
593
734
|
const result = standardSchema[`~standard`].validate(mergedData)
|
|
@@ -664,28 +805,24 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
664
805
|
const items = Array.isArray(data) ? data : [data]
|
|
665
806
|
const mutations: Array<PendingMutation<T>> = []
|
|
666
807
|
|
|
667
|
-
// Handle keys - convert to array if string, or generate if not provided
|
|
668
|
-
const keys: Array<unknown> = items.map((item) =>
|
|
669
|
-
this.generateObjectKey(this.config.getId(item), item)
|
|
670
|
-
)
|
|
671
|
-
|
|
672
808
|
// Create mutations for each item
|
|
673
|
-
items.forEach((item
|
|
809
|
+
items.forEach((item) => {
|
|
674
810
|
// Validate the data against the schema if one exists
|
|
675
811
|
const validatedData = this.validateData(item, `insert`)
|
|
676
|
-
const key = keys[index]!
|
|
677
812
|
|
|
678
813
|
// Check if an item with this ID already exists in the collection
|
|
679
|
-
const
|
|
680
|
-
if (this.
|
|
681
|
-
throw `Cannot insert document with ID "${
|
|
814
|
+
const key = this.getKeyFromItem(item)
|
|
815
|
+
if (this.has(key)) {
|
|
816
|
+
throw `Cannot insert document with ID "${key}" because it already exists in the collection`
|
|
682
817
|
}
|
|
818
|
+
const globalKey = this.generateGlobalKey(key, item)
|
|
683
819
|
|
|
684
820
|
const mutation: PendingMutation<T> = {
|
|
685
821
|
mutationId: crypto.randomUUID(),
|
|
686
822
|
original: {},
|
|
687
|
-
modified: validatedData
|
|
688
|
-
changes: validatedData
|
|
823
|
+
modified: validatedData,
|
|
824
|
+
changes: validatedData,
|
|
825
|
+
globalKey,
|
|
689
826
|
key,
|
|
690
827
|
metadata: config?.metadata as unknown,
|
|
691
828
|
syncMetadata: this.config.sync.getSyncMetadata?.() || {},
|
|
@@ -702,15 +839,13 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
702
839
|
if (ambientTransaction) {
|
|
703
840
|
ambientTransaction.applyMutations(mutations)
|
|
704
841
|
|
|
705
|
-
this.transactions.
|
|
706
|
-
|
|
707
|
-
return sortedMap
|
|
708
|
-
})
|
|
842
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
843
|
+
this.recomputeOptimisticState()
|
|
709
844
|
|
|
710
845
|
return ambientTransaction
|
|
711
846
|
} else {
|
|
712
847
|
// Create a new transaction with a mutation function that calls the onInsert handler
|
|
713
|
-
const directOpTransaction = new Transaction({
|
|
848
|
+
const directOpTransaction = new Transaction<T>({
|
|
714
849
|
mutationFn: async (params) => {
|
|
715
850
|
// Call the onInsert handler with the transaction
|
|
716
851
|
return this.config.onInsert!(params)
|
|
@@ -722,10 +857,8 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
722
857
|
directOpTransaction.commit()
|
|
723
858
|
|
|
724
859
|
// Add the transaction to the collection's transactions store
|
|
725
|
-
this.transactions.
|
|
726
|
-
|
|
727
|
-
return sortedMap
|
|
728
|
-
})
|
|
860
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
861
|
+
this.recomputeOptimisticState()
|
|
729
862
|
|
|
730
863
|
return directOpTransaction
|
|
731
864
|
}
|
|
@@ -770,24 +903,38 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
770
903
|
* // Update with metadata
|
|
771
904
|
* update("todo-1", { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" })
|
|
772
905
|
*/
|
|
906
|
+
// Overload 1: Update multiple items with a callback
|
|
907
|
+
update<TItem extends object = T>(
|
|
908
|
+
key: Array<TKey | unknown>,
|
|
909
|
+
callback: (drafts: Array<TItem>) => void
|
|
910
|
+
): TransactionType
|
|
911
|
+
|
|
912
|
+
// Overload 2: Update multiple items with config and a callback
|
|
773
913
|
update<TItem extends object = T>(
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
914
|
+
keys: Array<TKey | unknown>,
|
|
915
|
+
config: OperationConfig,
|
|
916
|
+
callback: (drafts: Array<TItem>) => void
|
|
777
917
|
): TransactionType
|
|
778
918
|
|
|
919
|
+
// Overload 3: Update a single item with a callback
|
|
779
920
|
update<TItem extends object = T>(
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
maybeCallback?: (draft: Array<TItem>) => void
|
|
921
|
+
id: TKey | unknown,
|
|
922
|
+
callback: (draft: TItem) => void
|
|
783
923
|
): TransactionType
|
|
784
924
|
|
|
925
|
+
// Overload 4: Update a single item with config and a callback
|
|
785
926
|
update<TItem extends object = T>(
|
|
786
|
-
|
|
927
|
+
id: TKey | unknown,
|
|
928
|
+
config: OperationConfig,
|
|
929
|
+
callback: (draft: TItem) => void
|
|
930
|
+
): TransactionType
|
|
931
|
+
|
|
932
|
+
update<TItem extends object = T>(
|
|
933
|
+
keys: (TKey | unknown) | Array<TKey | unknown>,
|
|
787
934
|
configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig,
|
|
788
935
|
maybeCallback?: (draft: TItem | Array<TItem>) => void
|
|
789
936
|
) {
|
|
790
|
-
if (typeof
|
|
937
|
+
if (typeof keys === `undefined`) {
|
|
791
938
|
throw new Error(`The first argument to update is missing`)
|
|
792
939
|
}
|
|
793
940
|
|
|
@@ -800,21 +947,24 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
800
947
|
)
|
|
801
948
|
}
|
|
802
949
|
|
|
803
|
-
const isArray = Array.isArray(
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
)
|
|
950
|
+
const isArray = Array.isArray(keys)
|
|
951
|
+
const keysArray = isArray ? keys : [keys]
|
|
952
|
+
|
|
953
|
+
if (isArray && keysArray.length === 0) {
|
|
954
|
+
throw new Error(`No keys were passed to update`)
|
|
955
|
+
}
|
|
956
|
+
|
|
807
957
|
const callback =
|
|
808
958
|
typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
|
|
809
959
|
const config =
|
|
810
960
|
typeof configOrCallback === `function` ? {} : configOrCallback
|
|
811
961
|
|
|
812
962
|
// Get the current objects or empty objects if they don't exist
|
|
813
|
-
const currentObjects =
|
|
814
|
-
const item = this.
|
|
963
|
+
const currentObjects = keysArray.map((key) => {
|
|
964
|
+
const item = this.get(key)
|
|
815
965
|
if (!item) {
|
|
816
966
|
throw new Error(
|
|
817
|
-
`The
|
|
967
|
+
`The key "${key}" was passed to update but an object for this key was not found in the collection`
|
|
818
968
|
)
|
|
819
969
|
}
|
|
820
970
|
|
|
@@ -830,15 +980,15 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
830
980
|
)
|
|
831
981
|
} else {
|
|
832
982
|
const result = withChangeTracking(
|
|
833
|
-
currentObjects[0]
|
|
983
|
+
currentObjects[0]!,
|
|
834
984
|
callback as (draft: TItem) => void
|
|
835
985
|
)
|
|
836
986
|
changesArray = [result]
|
|
837
987
|
}
|
|
838
988
|
|
|
839
989
|
// Create mutations for each object that has changes
|
|
840
|
-
const mutations: Array<PendingMutation<T>> =
|
|
841
|
-
.map((
|
|
990
|
+
const mutations: Array<PendingMutation<T>> = keysArray
|
|
991
|
+
.map((key, index) => {
|
|
842
992
|
const itemChanges = changesArray[index] // User-provided changes for this specific item
|
|
843
993
|
|
|
844
994
|
// Skip items with no changes
|
|
@@ -851,30 +1001,37 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
851
1001
|
const validatedUpdatePayload = this.validateData(
|
|
852
1002
|
itemChanges,
|
|
853
1003
|
`update`,
|
|
854
|
-
|
|
1004
|
+
key
|
|
855
1005
|
)
|
|
856
1006
|
|
|
857
1007
|
// Construct the full modified item by applying the validated update payload to the original item
|
|
858
|
-
const modifiedItem =
|
|
1008
|
+
const modifiedItem = Object.assign(
|
|
1009
|
+
{},
|
|
1010
|
+
originalItem,
|
|
1011
|
+
validatedUpdatePayload
|
|
1012
|
+
)
|
|
859
1013
|
|
|
860
1014
|
// Check if the ID of the item is being changed
|
|
861
|
-
const originalItemId = this.
|
|
862
|
-
const modifiedItemId = this.
|
|
1015
|
+
const originalItemId = this.getKeyFromItem(originalItem)
|
|
1016
|
+
const modifiedItemId = this.getKeyFromItem(modifiedItem)
|
|
863
1017
|
|
|
864
1018
|
if (originalItemId !== modifiedItemId) {
|
|
865
1019
|
throw new Error(
|
|
866
|
-
`Updating the
|
|
1020
|
+
`Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.`
|
|
867
1021
|
)
|
|
868
1022
|
}
|
|
869
1023
|
|
|
1024
|
+
const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem)
|
|
1025
|
+
|
|
870
1026
|
return {
|
|
871
1027
|
mutationId: crypto.randomUUID(),
|
|
872
1028
|
original: originalItem as Record<string, unknown>,
|
|
873
1029
|
modified: modifiedItem as Record<string, unknown>,
|
|
874
1030
|
changes: validatedUpdatePayload as Record<string, unknown>,
|
|
875
|
-
|
|
1031
|
+
globalKey,
|
|
1032
|
+
key,
|
|
876
1033
|
metadata: config.metadata as unknown,
|
|
877
|
-
syncMetadata: (this.syncedMetadata.
|
|
1034
|
+
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
|
|
878
1035
|
string,
|
|
879
1036
|
unknown
|
|
880
1037
|
>,
|
|
@@ -895,10 +1052,8 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
895
1052
|
if (ambientTransaction) {
|
|
896
1053
|
ambientTransaction.applyMutations(mutations)
|
|
897
1054
|
|
|
898
|
-
this.transactions.
|
|
899
|
-
|
|
900
|
-
return sortedMap
|
|
901
|
-
})
|
|
1055
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
1056
|
+
this.recomputeOptimisticState()
|
|
902
1057
|
|
|
903
1058
|
return ambientTransaction
|
|
904
1059
|
}
|
|
@@ -906,10 +1061,10 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
906
1061
|
// No need to check for onUpdate handler here as we've already checked at the beginning
|
|
907
1062
|
|
|
908
1063
|
// Create a new transaction with a mutation function that calls the onUpdate handler
|
|
909
|
-
const directOpTransaction = new Transaction({
|
|
910
|
-
mutationFn: async (
|
|
1064
|
+
const directOpTransaction = new Transaction<T>({
|
|
1065
|
+
mutationFn: async (params) => {
|
|
911
1066
|
// Call the onUpdate handler with the transaction
|
|
912
|
-
return this.config.onUpdate!(
|
|
1067
|
+
return this.config.onUpdate!(params)
|
|
913
1068
|
},
|
|
914
1069
|
})
|
|
915
1070
|
|
|
@@ -918,10 +1073,9 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
918
1073
|
directOpTransaction.commit()
|
|
919
1074
|
|
|
920
1075
|
// Add the transaction to the collection's transactions store
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
})
|
|
1076
|
+
|
|
1077
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
1078
|
+
this.recomputeOptimisticState()
|
|
925
1079
|
|
|
926
1080
|
return directOpTransaction
|
|
927
1081
|
}
|
|
@@ -942,9 +1096,9 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
942
1096
|
* delete("todo-1", { metadata: { reason: "completed" } })
|
|
943
1097
|
*/
|
|
944
1098
|
delete = (
|
|
945
|
-
|
|
1099
|
+
keys: Array<TKey> | TKey,
|
|
946
1100
|
config?: OperationConfig
|
|
947
|
-
): TransactionType => {
|
|
1101
|
+
): TransactionType<any> => {
|
|
948
1102
|
const ambientTransaction = getActiveTransaction()
|
|
949
1103
|
|
|
950
1104
|
// If no ambient transaction exists, check for an onDelete handler early
|
|
@@ -954,20 +1108,24 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
954
1108
|
)
|
|
955
1109
|
}
|
|
956
1110
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1111
|
+
if (Array.isArray(keys) && keys.length === 0) {
|
|
1112
|
+
throw new Error(`No keys were passed to delete`)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const keysArray = Array.isArray(keys) ? keys : [keys]
|
|
960
1116
|
const mutations: Array<PendingMutation<T>> = []
|
|
961
1117
|
|
|
962
|
-
for (const
|
|
1118
|
+
for (const key of keysArray) {
|
|
1119
|
+
const globalKey = this.generateGlobalKey(key, this.get(key)!)
|
|
963
1120
|
const mutation: PendingMutation<T> = {
|
|
964
1121
|
mutationId: crypto.randomUUID(),
|
|
965
|
-
original:
|
|
966
|
-
modified:
|
|
967
|
-
changes:
|
|
968
|
-
|
|
1122
|
+
original: this.get(key) || {},
|
|
1123
|
+
modified: this.get(key)!,
|
|
1124
|
+
changes: this.get(key) || {},
|
|
1125
|
+
globalKey,
|
|
1126
|
+
key,
|
|
969
1127
|
metadata: config?.metadata as unknown,
|
|
970
|
-
syncMetadata: (this.syncedMetadata.
|
|
1128
|
+
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
|
|
971
1129
|
string,
|
|
972
1130
|
unknown
|
|
973
1131
|
>,
|
|
@@ -984,20 +1142,18 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
984
1142
|
if (ambientTransaction) {
|
|
985
1143
|
ambientTransaction.applyMutations(mutations)
|
|
986
1144
|
|
|
987
|
-
this.transactions.
|
|
988
|
-
|
|
989
|
-
return sortedMap
|
|
990
|
-
})
|
|
1145
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
1146
|
+
this.recomputeOptimisticState()
|
|
991
1147
|
|
|
992
1148
|
return ambientTransaction
|
|
993
1149
|
}
|
|
994
1150
|
|
|
995
1151
|
// Create a new transaction with a mutation function that calls the onDelete handler
|
|
996
|
-
const directOpTransaction = new Transaction({
|
|
1152
|
+
const directOpTransaction = new Transaction<T>({
|
|
997
1153
|
autoCommit: true,
|
|
998
|
-
mutationFn: async (
|
|
1154
|
+
mutationFn: async (params) => {
|
|
999
1155
|
// Call the onDelete handler with the transaction
|
|
1000
|
-
return this.config.onDelete!(
|
|
1156
|
+
return this.config.onDelete!(params)
|
|
1001
1157
|
},
|
|
1002
1158
|
})
|
|
1003
1159
|
|
|
@@ -1005,11 +1161,8 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1005
1161
|
directOpTransaction.applyMutations(mutations)
|
|
1006
1162
|
directOpTransaction.commit()
|
|
1007
1163
|
|
|
1008
|
-
|
|
1009
|
-
this.
|
|
1010
|
-
sortedMap.set(directOpTransaction.id, directOpTransaction)
|
|
1011
|
-
return sortedMap
|
|
1012
|
-
})
|
|
1164
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
1165
|
+
this.recomputeOptimisticState()
|
|
1013
1166
|
|
|
1014
1167
|
return directOpTransaction
|
|
1015
1168
|
}
|
|
@@ -1020,7 +1173,11 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1020
1173
|
* @returns A Map containing all items in the collection, with keys as identifiers
|
|
1021
1174
|
*/
|
|
1022
1175
|
get state() {
|
|
1023
|
-
|
|
1176
|
+
const result = new Map<TKey, T>()
|
|
1177
|
+
for (const [key, value] of this.entries()) {
|
|
1178
|
+
result.set(key, value)
|
|
1179
|
+
}
|
|
1180
|
+
return result
|
|
1024
1181
|
}
|
|
1025
1182
|
|
|
1026
1183
|
/**
|
|
@@ -1029,14 +1186,14 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1029
1186
|
*
|
|
1030
1187
|
* @returns Promise that resolves to a Map containing all items in the collection
|
|
1031
1188
|
*/
|
|
1032
|
-
stateWhenReady(): Promise<Map<
|
|
1189
|
+
stateWhenReady(): Promise<Map<TKey, T>> {
|
|
1033
1190
|
// If we already have data or there are no loading collections, resolve immediately
|
|
1034
|
-
if (this.
|
|
1191
|
+
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
|
|
1035
1192
|
return Promise.resolve(this.state)
|
|
1036
1193
|
}
|
|
1037
1194
|
|
|
1038
1195
|
// Otherwise, wait for the first commit
|
|
1039
|
-
return new Promise<Map<
|
|
1196
|
+
return new Promise<Map<TKey, T>>((resolve) => {
|
|
1040
1197
|
this.onFirstCommit(() => {
|
|
1041
1198
|
resolve(this.state)
|
|
1042
1199
|
})
|
|
@@ -1049,7 +1206,19 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1049
1206
|
* @returns An Array containing all items in the collection
|
|
1050
1207
|
*/
|
|
1051
1208
|
get toArray() {
|
|
1052
|
-
|
|
1209
|
+
const array = Array.from(this.values())
|
|
1210
|
+
|
|
1211
|
+
// Currently a query with an orderBy will add a _orderByIndex to the items
|
|
1212
|
+
// so for now we need to sort the array by _orderByIndex if it exists
|
|
1213
|
+
// TODO: in the future it would be much better is the keys are sorted - this
|
|
1214
|
+
// should be done by the query engine.
|
|
1215
|
+
if (array[0] && (array[0] as { _orderByIndex?: number })._orderByIndex) {
|
|
1216
|
+
return (array as Array<{ _orderByIndex: number }>).sort(
|
|
1217
|
+
(a, b) => a._orderByIndex - b._orderByIndex
|
|
1218
|
+
) as Array<T>
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return array
|
|
1053
1222
|
}
|
|
1054
1223
|
|
|
1055
1224
|
/**
|
|
@@ -1060,7 +1229,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1060
1229
|
*/
|
|
1061
1230
|
toArrayWhenReady(): Promise<Array<T>> {
|
|
1062
1231
|
// If we already have data or there are no loading collections, resolve immediately
|
|
1063
|
-
if (this.
|
|
1232
|
+
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
|
|
1064
1233
|
return Promise.resolve(this.toArray)
|
|
1065
1234
|
}
|
|
1066
1235
|
|
|
@@ -1077,7 +1246,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1077
1246
|
* @returns An array of changes
|
|
1078
1247
|
*/
|
|
1079
1248
|
public currentStateAsChanges(): Array<ChangeMessage<T>> {
|
|
1080
|
-
return
|
|
1249
|
+
return Array.from(this.entries()).map(([key, value]) => ({
|
|
1081
1250
|
type: `insert`,
|
|
1082
1251
|
key,
|
|
1083
1252
|
value,
|
|
@@ -1090,16 +1259,99 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
|
|
|
1090
1259
|
* @returns A function that can be called to unsubscribe from the changes
|
|
1091
1260
|
*/
|
|
1092
1261
|
public subscribeChanges(
|
|
1093
|
-
callback: (changes: Array<ChangeMessage<T>>) => void
|
|
1262
|
+
callback: (changes: Array<ChangeMessage<T>>) => void,
|
|
1263
|
+
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1094
1264
|
): () => void {
|
|
1095
|
-
|
|
1096
|
-
|
|
1265
|
+
if (includeInitialState) {
|
|
1266
|
+
// First send the current state as changes
|
|
1267
|
+
callback(this.currentStateAsChanges())
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Add to batched listeners
|
|
1271
|
+
this.changeListeners.add(callback)
|
|
1097
1272
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1273
|
+
return () => {
|
|
1274
|
+
this.changeListeners.delete(callback)
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Subscribe to changes for a specific key
|
|
1280
|
+
*/
|
|
1281
|
+
public subscribeChangesKey(
|
|
1282
|
+
key: TKey,
|
|
1283
|
+
listener: ChangeListener<T, TKey>,
|
|
1284
|
+
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1285
|
+
): () => void {
|
|
1286
|
+
if (!this.changeKeyListeners.has(key)) {
|
|
1287
|
+
this.changeKeyListeners.set(key, new Set())
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (includeInitialState) {
|
|
1291
|
+
// First send the current state as changes
|
|
1292
|
+
listener([
|
|
1293
|
+
{
|
|
1294
|
+
type: `insert`,
|
|
1295
|
+
key,
|
|
1296
|
+
value: this.get(key)!,
|
|
1297
|
+
},
|
|
1298
|
+
])
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
this.changeKeyListeners.get(key)!.add(listener)
|
|
1302
|
+
|
|
1303
|
+
return () => {
|
|
1304
|
+
const listeners = this.changeKeyListeners.get(key)
|
|
1305
|
+
if (listeners) {
|
|
1306
|
+
listeners.delete(listener)
|
|
1307
|
+
if (listeners.size === 0) {
|
|
1308
|
+
this.changeKeyListeners.delete(key)
|
|
1309
|
+
}
|
|
1102
1310
|
}
|
|
1103
|
-
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Trigger a recomputation when transactions change
|
|
1316
|
+
* This method should be called by the Transaction class when state changes
|
|
1317
|
+
*/
|
|
1318
|
+
public onTransactionStateChange(): void {
|
|
1319
|
+
this.recomputeOptimisticState()
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private _storeMap: Store<Map<TKey, T>> | undefined
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Returns a Tanstack Store Map that is updated when the collection changes
|
|
1326
|
+
* This is a temporary solution to enable the existing framework hooks to work
|
|
1327
|
+
* with the new internals of Collection until they are rewritten.
|
|
1328
|
+
* TODO: Remove this once the framework hooks are rewritten.
|
|
1329
|
+
*/
|
|
1330
|
+
public asStoreMap(): Store<Map<TKey, T>> {
|
|
1331
|
+
if (!this._storeMap) {
|
|
1332
|
+
this._storeMap = new Store(new Map(this.entries()))
|
|
1333
|
+
this.subscribeChanges(() => {
|
|
1334
|
+
this._storeMap!.setState(() => new Map(this.entries()))
|
|
1335
|
+
})
|
|
1336
|
+
}
|
|
1337
|
+
return this._storeMap
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
private _storeArray: Store<Array<T>> | undefined
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Returns a Tanstack Store Array that is updated when the collection changes
|
|
1344
|
+
* This is a temporary solution to enable the existing framework hooks to work
|
|
1345
|
+
* with the new internals of Collection until they are rewritten.
|
|
1346
|
+
* TODO: Remove this once the framework hooks are rewritten.
|
|
1347
|
+
*/
|
|
1348
|
+
public asStoreArray(): Store<Array<T>> {
|
|
1349
|
+
if (!this._storeArray) {
|
|
1350
|
+
this._storeArray = new Store(this.toArray)
|
|
1351
|
+
this.subscribeChanges(() => {
|
|
1352
|
+
this._storeArray!.setState(() => this.toArray)
|
|
1353
|
+
})
|
|
1354
|
+
}
|
|
1355
|
+
return this._storeArray
|
|
1104
1356
|
}
|
|
1105
1357
|
}
|