@tanstack/db 0.0.6 → 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 +452 -286
- package/dist/cjs/collection.cjs.map +1 -1
- package/dist/cjs/collection.d.cts +115 -26
- package/dist/cjs/index.cjs +1 -1
- package/dist/cjs/index.d.cts +1 -1
- package/dist/cjs/proxy.cjs +2 -2
- package/dist/cjs/proxy.cjs.map +1 -1
- package/dist/cjs/query/compiled-query.cjs +24 -38
- 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 +36 -22
- package/dist/esm/collection.d.ts +115 -26
- package/dist/esm/collection.js +453 -287
- package/dist/esm/collection.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -2
- 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 +25 -39
- 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 +36 -22
- package/package.json +2 -2
- package/src/collection.ts +652 -368
- package/src/index.ts +1 -1
- package/src/proxy.ts +2 -2
- package/src/query/compiled-query.ts +29 -39
- 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 +54 -22
package/src/collection.ts
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
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,
|
|
9
|
+
Fn,
|
|
8
10
|
InsertConfig,
|
|
9
11
|
OperationConfig,
|
|
10
12
|
OptimisticChangeMessage,
|
|
11
13
|
PendingMutation,
|
|
12
14
|
StandardSchema,
|
|
13
15
|
Transaction as TransactionType,
|
|
16
|
+
UtilsRecord,
|
|
14
17
|
} from "./types"
|
|
15
18
|
|
|
16
|
-
// Store collections in memory
|
|
17
|
-
export const collectionsStore = new
|
|
19
|
+
// Store collections in memory
|
|
20
|
+
export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
|
|
18
21
|
|
|
19
22
|
// Map to track loading collections
|
|
20
|
-
|
|
21
|
-
const loadingCollections = new Map<
|
|
23
|
+
const loadingCollectionResolvers = new Map<
|
|
22
24
|
string,
|
|
23
|
-
|
|
25
|
+
{
|
|
26
|
+
promise: Promise<CollectionImpl<any, any>>
|
|
27
|
+
resolve: (value: CollectionImpl<any, any>) => void
|
|
28
|
+
}
|
|
24
29
|
>()
|
|
25
30
|
|
|
26
31
|
interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
@@ -28,17 +33,45 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
|
|
|
28
33
|
operations: Array<OptimisticChangeMessage<T>>
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Enhanced Collection interface that includes both data type T and utilities TUtils
|
|
38
|
+
* @template T - The type of items in the collection
|
|
39
|
+
* @template TUtils - The utilities record type
|
|
40
|
+
*/
|
|
41
|
+
export interface Collection<
|
|
42
|
+
T extends object = Record<string, unknown>,
|
|
43
|
+
TKey extends string | number = string | number,
|
|
44
|
+
TUtils extends UtilsRecord = {},
|
|
45
|
+
> extends CollectionImpl<T, TKey> {
|
|
46
|
+
readonly utils: TUtils
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
/**
|
|
32
50
|
* Creates a new Collection instance with the given configuration
|
|
33
51
|
*
|
|
34
52
|
* @template T - The type of items in the collection
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
53
|
+
* @template TKey - The type of the key for the collection
|
|
54
|
+
* @template TUtils - The utilities record type
|
|
55
|
+
* @param options - Collection options with optional utilities
|
|
56
|
+
* @returns A new Collection with utilities exposed both at top level and under .utils
|
|
37
57
|
*/
|
|
38
|
-
export function createCollection<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
export function createCollection<
|
|
59
|
+
T extends object = Record<string, unknown>,
|
|
60
|
+
TKey extends string | number = string | number,
|
|
61
|
+
TUtils extends UtilsRecord = {},
|
|
62
|
+
>(
|
|
63
|
+
options: CollectionConfig<T, TKey> & { utils?: TUtils }
|
|
64
|
+
): Collection<T, TKey, TUtils> {
|
|
65
|
+
const collection = new CollectionImpl<T, TKey>(options)
|
|
66
|
+
|
|
67
|
+
// Copy utils to both top level and .utils namespace
|
|
68
|
+
if (options.utils) {
|
|
69
|
+
collection.utils = { ...options.utils }
|
|
70
|
+
} else {
|
|
71
|
+
collection.utils = {} as TUtils
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return collection as Collection<T, TKey, TUtils>
|
|
42
75
|
}
|
|
43
76
|
|
|
44
77
|
/**
|
|
@@ -67,56 +100,54 @@ export function createCollection<T extends object = Record<string, unknown>>(
|
|
|
67
100
|
* @param config - Configuration for the collection, including id and sync
|
|
68
101
|
* @returns Promise that resolves when the initial sync is finished
|
|
69
102
|
*/
|
|
70
|
-
export function preloadCollection<
|
|
71
|
-
|
|
72
|
-
|
|
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>> {
|
|
73
107
|
if (!config.id) {
|
|
74
108
|
throw new Error(`The id property is required for preloadCollection`)
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
// If the collection is already fully loaded, return a resolved promise
|
|
78
112
|
if (
|
|
79
|
-
collectionsStore.
|
|
80
|
-
!
|
|
113
|
+
collectionsStore.has(config.id) &&
|
|
114
|
+
!loadingCollectionResolvers.has(config.id)
|
|
81
115
|
) {
|
|
82
116
|
return Promise.resolve(
|
|
83
|
-
collectionsStore.
|
|
117
|
+
collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
84
118
|
)
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
// If the collection is in the process of loading, return its promise
|
|
88
|
-
if (
|
|
89
|
-
return
|
|
122
|
+
if (loadingCollectionResolvers.has(config.id)) {
|
|
123
|
+
return loadingCollectionResolvers.get(config.id)!.promise
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
// Create a new collection instance if it doesn't exist
|
|
93
|
-
if (!collectionsStore.
|
|
94
|
-
collectionsStore.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
config.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
getId: config.getId,
|
|
104
|
-
sync: config.sync,
|
|
105
|
-
schema: config.schema,
|
|
106
|
-
})
|
|
107
|
-
)
|
|
108
|
-
return next
|
|
109
|
-
})
|
|
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
|
+
)
|
|
110
137
|
}
|
|
111
138
|
|
|
112
|
-
const collection = collectionsStore.
|
|
139
|
+
const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
|
|
113
140
|
|
|
114
141
|
// Create a promise that will resolve after the first commit
|
|
115
|
-
let resolveFirstCommit: () => void
|
|
116
|
-
const firstCommitPromise = new Promise<
|
|
117
|
-
resolveFirstCommit =
|
|
118
|
-
|
|
119
|
-
|
|
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!,
|
|
120
151
|
})
|
|
121
152
|
|
|
122
153
|
// Register a one-time listener for the first commit
|
|
@@ -124,18 +155,13 @@ export function preloadCollection<T extends object = Record<string, unknown>>(
|
|
|
124
155
|
if (!config.id) {
|
|
125
156
|
throw new Error(`The id property is required for preloadCollection`)
|
|
126
157
|
}
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
if (loadingCollectionResolvers.has(config.id)) {
|
|
159
|
+
const resolver = loadingCollectionResolvers.get(config.id)!
|
|
160
|
+
loadingCollectionResolvers.delete(config.id)
|
|
161
|
+
resolver.resolve(collection)
|
|
130
162
|
}
|
|
131
163
|
})
|
|
132
164
|
|
|
133
|
-
// Store the loading promise
|
|
134
|
-
loadingCollections.set(
|
|
135
|
-
config.id,
|
|
136
|
-
firstCommitPromise as Promise<Collection<Record<string, unknown>>>
|
|
137
|
-
)
|
|
138
|
-
|
|
139
165
|
return firstCommitPromise
|
|
140
166
|
}
|
|
141
167
|
|
|
@@ -168,17 +194,34 @@ export class SchemaValidationError extends Error {
|
|
|
168
194
|
}
|
|
169
195
|
}
|
|
170
196
|
|
|
171
|
-
export class
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
public
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
public
|
|
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
|
|
220
|
+
public utils: Record<string, Fn> = {}
|
|
221
|
+
|
|
179
222
|
private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
|
|
180
|
-
private syncedKeys = new Set<
|
|
181
|
-
public config: CollectionConfig<T>
|
|
223
|
+
private syncedKeys = new Set<TKey>()
|
|
224
|
+
public config: CollectionConfig<T, TKey>
|
|
182
225
|
private hasReceivedFirstCommit = false
|
|
183
226
|
|
|
184
227
|
// Array to store one-time commit listeners
|
|
@@ -201,7 +244,7 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
201
244
|
* @param config - Configuration object for the collection
|
|
202
245
|
* @throws Error if sync config is missing
|
|
203
246
|
*/
|
|
204
|
-
constructor(config: CollectionConfig<T>) {
|
|
247
|
+
constructor(config: CollectionConfig<T, TKey>) {
|
|
205
248
|
// eslint-disable-next-line
|
|
206
249
|
if (!config) {
|
|
207
250
|
throw new Error(`Collection requires a config`)
|
|
@@ -217,156 +260,12 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
217
260
|
throw new Error(`Collection requires a sync config`)
|
|
218
261
|
}
|
|
219
262
|
|
|
220
|
-
this.transactions = new
|
|
221
|
-
|
|
222
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
223
|
-
)
|
|
263
|
+
this.transactions = new SortedMap<string, Transaction<any>>(
|
|
264
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
224
265
|
)
|
|
225
266
|
|
|
226
|
-
// Copies of live mutations are stored here and removed once the transaction completes.
|
|
227
|
-
this.optimisticOperations = new Derived({
|
|
228
|
-
fn: ({ currDepVals: [transactions] }) => {
|
|
229
|
-
const result = Array.from(transactions.values())
|
|
230
|
-
.map((transaction) => {
|
|
231
|
-
const isActive = ![`completed`, `failed`].includes(
|
|
232
|
-
transaction.state
|
|
233
|
-
)
|
|
234
|
-
return transaction.mutations
|
|
235
|
-
.filter((mutation) => mutation.collection === this)
|
|
236
|
-
.map((mutation) => {
|
|
237
|
-
const message: OptimisticChangeMessage<T> = {
|
|
238
|
-
type: mutation.type,
|
|
239
|
-
key: mutation.key,
|
|
240
|
-
value: mutation.modified as T,
|
|
241
|
-
isActive,
|
|
242
|
-
}
|
|
243
|
-
if (
|
|
244
|
-
mutation.metadata !== undefined &&
|
|
245
|
-
mutation.metadata !== null
|
|
246
|
-
) {
|
|
247
|
-
message.metadata = mutation.metadata as Record<
|
|
248
|
-
string,
|
|
249
|
-
unknown
|
|
250
|
-
>
|
|
251
|
-
}
|
|
252
|
-
return message
|
|
253
|
-
})
|
|
254
|
-
})
|
|
255
|
-
.flat()
|
|
256
|
-
|
|
257
|
-
return result
|
|
258
|
-
},
|
|
259
|
-
deps: [this.transactions],
|
|
260
|
-
})
|
|
261
|
-
this.optimisticOperations.mount()
|
|
262
|
-
|
|
263
|
-
// Combine together synced data & optimistic operations.
|
|
264
|
-
this.derivedState = new Derived({
|
|
265
|
-
fn: ({ currDepVals: [syncedData, operations] }) => {
|
|
266
|
-
const combined = new Map<string, T>(syncedData)
|
|
267
|
-
|
|
268
|
-
// Apply the optimistic operations on top of the synced state.
|
|
269
|
-
for (const operation of operations) {
|
|
270
|
-
if (operation.isActive) {
|
|
271
|
-
switch (operation.type) {
|
|
272
|
-
case `insert`:
|
|
273
|
-
combined.set(operation.key, operation.value)
|
|
274
|
-
break
|
|
275
|
-
case `update`:
|
|
276
|
-
combined.set(operation.key, operation.value)
|
|
277
|
-
break
|
|
278
|
-
case `delete`:
|
|
279
|
-
combined.delete(operation.key)
|
|
280
|
-
break
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
return combined
|
|
286
|
-
},
|
|
287
|
-
deps: [this.syncedData, this.optimisticOperations],
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
// Create a derived array from the map to avoid recalculating it
|
|
291
|
-
this.derivedArray = new Derived({
|
|
292
|
-
fn: ({ currDepVals: [stateMap] }) => {
|
|
293
|
-
// Collections returned by a query that has an orderBy are annotated
|
|
294
|
-
// with the _orderByIndex field.
|
|
295
|
-
// This is used to sort the array when it's derived.
|
|
296
|
-
const array: Array<T & { _orderByIndex?: number }> = Array.from(
|
|
297
|
-
stateMap.values()
|
|
298
|
-
)
|
|
299
|
-
if (array[0] && `_orderByIndex` in array[0]) {
|
|
300
|
-
;(array as Array<T & { _orderByIndex: number }>).sort((a, b) => {
|
|
301
|
-
if (a._orderByIndex === b._orderByIndex) {
|
|
302
|
-
return 0
|
|
303
|
-
}
|
|
304
|
-
return a._orderByIndex < b._orderByIndex ? -1 : 1
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
return array
|
|
308
|
-
},
|
|
309
|
-
deps: [this.derivedState],
|
|
310
|
-
})
|
|
311
|
-
this.derivedArray.mount()
|
|
312
|
-
|
|
313
|
-
this.derivedChanges = new Derived({
|
|
314
|
-
fn: ({
|
|
315
|
-
currDepVals: [derivedState, optimisticOperations],
|
|
316
|
-
prevDepVals,
|
|
317
|
-
}) => {
|
|
318
|
-
const prevDerivedState = prevDepVals?.[0] ?? new Map<string, T>()
|
|
319
|
-
const prevOptimisticOperations = prevDepVals?.[1] ?? []
|
|
320
|
-
const changedKeys = new Set(this.syncedKeys)
|
|
321
|
-
optimisticOperations
|
|
322
|
-
.flat()
|
|
323
|
-
.filter((op) => op.isActive)
|
|
324
|
-
.forEach((op) => changedKeys.add(op.key))
|
|
325
|
-
prevOptimisticOperations.flat().forEach((op) => {
|
|
326
|
-
changedKeys.add(op.key)
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
if (changedKeys.size === 0) {
|
|
330
|
-
return []
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const changes: Array<ChangeMessage<T>> = []
|
|
334
|
-
for (const key of changedKeys) {
|
|
335
|
-
if (prevDerivedState.has(key) && !derivedState.has(key)) {
|
|
336
|
-
changes.push({
|
|
337
|
-
type: `delete`,
|
|
338
|
-
key,
|
|
339
|
-
value: prevDerivedState.get(key)!,
|
|
340
|
-
})
|
|
341
|
-
} else if (!prevDerivedState.has(key) && derivedState.has(key)) {
|
|
342
|
-
changes.push({ type: `insert`, key, value: derivedState.get(key)! })
|
|
343
|
-
} else if (prevDerivedState.has(key) && derivedState.has(key)) {
|
|
344
|
-
const value = derivedState.get(key)!
|
|
345
|
-
const previousValue = prevDerivedState.get(key)
|
|
346
|
-
if (value !== previousValue) {
|
|
347
|
-
// Comparing objects by reference as records are not mutated
|
|
348
|
-
changes.push({
|
|
349
|
-
type: `update`,
|
|
350
|
-
key,
|
|
351
|
-
value,
|
|
352
|
-
previousValue,
|
|
353
|
-
})
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
this.syncedKeys.clear()
|
|
359
|
-
|
|
360
|
-
return changes
|
|
361
|
-
},
|
|
362
|
-
deps: [this.derivedState, this.optimisticOperations],
|
|
363
|
-
})
|
|
364
|
-
this.derivedChanges.mount()
|
|
365
|
-
|
|
366
267
|
this.config = config
|
|
367
268
|
|
|
368
|
-
this.derivedState.mount()
|
|
369
|
-
|
|
370
269
|
// Start the sync process
|
|
371
270
|
config.sync.sync({
|
|
372
271
|
collection: this,
|
|
@@ -389,22 +288,18 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
389
288
|
`The pending sync transaction is already committed, you can't still write to it.`
|
|
390
289
|
)
|
|
391
290
|
}
|
|
392
|
-
const key = this.
|
|
393
|
-
this.config.getId(messageWithoutKey.value),
|
|
394
|
-
messageWithoutKey.value
|
|
395
|
-
)
|
|
291
|
+
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
396
292
|
|
|
397
|
-
// Check if an item with this
|
|
293
|
+
// Check if an item with this key already exists when inserting
|
|
398
294
|
if (messageWithoutKey.type === `insert`) {
|
|
399
295
|
if (
|
|
400
|
-
this.syncedData.
|
|
296
|
+
this.syncedData.has(key) &&
|
|
401
297
|
!pendingTransaction.operations.some(
|
|
402
298
|
(op) => op.key === key && op.type === `delete`
|
|
403
299
|
)
|
|
404
300
|
) {
|
|
405
|
-
const id = this.config.getId(messageWithoutKey.value)
|
|
406
301
|
throw new Error(
|
|
407
|
-
`Cannot insert document with
|
|
302
|
+
`Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
|
|
408
303
|
)
|
|
409
304
|
}
|
|
410
305
|
}
|
|
@@ -430,63 +325,350 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
430
325
|
}
|
|
431
326
|
|
|
432
327
|
pendingTransaction.committed = true
|
|
433
|
-
|
|
434
328
|
this.commitPendingTransactions()
|
|
435
329
|
},
|
|
436
330
|
})
|
|
437
331
|
}
|
|
438
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
|
+
|
|
439
572
|
/**
|
|
440
573
|
* Attempts to commit pending synced transactions if there are no active transactions
|
|
441
574
|
* This method processes operations from pending transactions and applies them to the synced data
|
|
442
575
|
*/
|
|
443
576
|
commitPendingTransactions = () => {
|
|
444
577
|
if (
|
|
445
|
-
!Array.from(this.transactions.
|
|
578
|
+
!Array.from(this.transactions.values()).some(
|
|
446
579
|
({ state }) => state === `persisting`
|
|
447
580
|
)
|
|
448
581
|
) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
})
|
|
467
645
|
}
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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,
|
|
479
659
|
})
|
|
480
|
-
|
|
481
|
-
case `delete`:
|
|
482
|
-
prevData.delete(operation.key)
|
|
483
|
-
break
|
|
660
|
+
}
|
|
484
661
|
}
|
|
485
|
-
|
|
486
|
-
})
|
|
662
|
+
break
|
|
487
663
|
}
|
|
488
664
|
}
|
|
489
|
-
}
|
|
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)
|
|
490
672
|
|
|
491
673
|
this.pendingSyncedTransactions = []
|
|
492
674
|
|
|
@@ -511,33 +693,24 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
511
693
|
)
|
|
512
694
|
}
|
|
513
695
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
throw new Error(`id is undefined`)
|
|
517
|
-
}
|
|
518
|
-
if (typeof id === `string` && id.startsWith(`KEY::`)) {
|
|
519
|
-
return id
|
|
520
|
-
} else {
|
|
521
|
-
// if it's not a string, then it's some other
|
|
522
|
-
// primitive type and needs turned into a key.
|
|
523
|
-
return this.generateObjectKey(id, null)
|
|
524
|
-
}
|
|
696
|
+
public getKeyFromItem(item: T): TKey {
|
|
697
|
+
return this.config.getKey(item)
|
|
525
698
|
}
|
|
526
699
|
|
|
527
|
-
public
|
|
528
|
-
if (typeof
|
|
700
|
+
public generateGlobalKey(key: any, item: any): string {
|
|
701
|
+
if (typeof key === `undefined`) {
|
|
529
702
|
throw new Error(
|
|
530
|
-
`An object was created without a defined
|
|
703
|
+
`An object was created without a defined key: ${JSON.stringify(item)}`
|
|
531
704
|
)
|
|
532
705
|
}
|
|
533
706
|
|
|
534
|
-
return `KEY::${this.id}/${
|
|
707
|
+
return `KEY::${this.id}/${key}`
|
|
535
708
|
}
|
|
536
709
|
|
|
537
710
|
private validateData(
|
|
538
711
|
data: unknown,
|
|
539
712
|
type: `insert` | `update`,
|
|
540
|
-
key?:
|
|
713
|
+
key?: TKey
|
|
541
714
|
): T | never {
|
|
542
715
|
if (!this.config.schema) return data as T
|
|
543
716
|
|
|
@@ -546,7 +719,7 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
546
719
|
// For updates, we need to merge with the existing data before validation
|
|
547
720
|
if (type === `update` && key) {
|
|
548
721
|
// Get the existing data for this key
|
|
549
|
-
const existingData = this.
|
|
722
|
+
const existingData = this.get(key)
|
|
550
723
|
|
|
551
724
|
if (
|
|
552
725
|
existingData &&
|
|
@@ -555,7 +728,7 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
555
728
|
typeof existingData === `object`
|
|
556
729
|
) {
|
|
557
730
|
// Merge the update with the existing data
|
|
558
|
-
const mergedData = {
|
|
731
|
+
const mergedData = Object.assign({}, existingData, data)
|
|
559
732
|
|
|
560
733
|
// Validate the merged data
|
|
561
734
|
const result = standardSchema[`~standard`].validate(mergedData)
|
|
@@ -632,28 +805,24 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
632
805
|
const items = Array.isArray(data) ? data : [data]
|
|
633
806
|
const mutations: Array<PendingMutation<T>> = []
|
|
634
807
|
|
|
635
|
-
// Handle keys - convert to array if string, or generate if not provided
|
|
636
|
-
const keys: Array<unknown> = items.map((item) =>
|
|
637
|
-
this.generateObjectKey(this.config.getId(item), item)
|
|
638
|
-
)
|
|
639
|
-
|
|
640
808
|
// Create mutations for each item
|
|
641
|
-
items.forEach((item
|
|
809
|
+
items.forEach((item) => {
|
|
642
810
|
// Validate the data against the schema if one exists
|
|
643
811
|
const validatedData = this.validateData(item, `insert`)
|
|
644
|
-
const key = keys[index]!
|
|
645
812
|
|
|
646
813
|
// Check if an item with this ID already exists in the collection
|
|
647
|
-
const
|
|
648
|
-
if (this.
|
|
649
|
-
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`
|
|
650
817
|
}
|
|
818
|
+
const globalKey = this.generateGlobalKey(key, item)
|
|
651
819
|
|
|
652
820
|
const mutation: PendingMutation<T> = {
|
|
653
821
|
mutationId: crypto.randomUUID(),
|
|
654
822
|
original: {},
|
|
655
|
-
modified: validatedData
|
|
656
|
-
changes: validatedData
|
|
823
|
+
modified: validatedData,
|
|
824
|
+
changes: validatedData,
|
|
825
|
+
globalKey,
|
|
657
826
|
key,
|
|
658
827
|
metadata: config?.metadata as unknown,
|
|
659
828
|
syncMetadata: this.config.sync.getSyncMetadata?.() || {},
|
|
@@ -670,15 +839,13 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
670
839
|
if (ambientTransaction) {
|
|
671
840
|
ambientTransaction.applyMutations(mutations)
|
|
672
841
|
|
|
673
|
-
this.transactions.
|
|
674
|
-
|
|
675
|
-
return sortedMap
|
|
676
|
-
})
|
|
842
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
843
|
+
this.recomputeOptimisticState()
|
|
677
844
|
|
|
678
845
|
return ambientTransaction
|
|
679
846
|
} else {
|
|
680
847
|
// Create a new transaction with a mutation function that calls the onInsert handler
|
|
681
|
-
const directOpTransaction = new Transaction({
|
|
848
|
+
const directOpTransaction = new Transaction<T>({
|
|
682
849
|
mutationFn: async (params) => {
|
|
683
850
|
// Call the onInsert handler with the transaction
|
|
684
851
|
return this.config.onInsert!(params)
|
|
@@ -690,10 +857,8 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
690
857
|
directOpTransaction.commit()
|
|
691
858
|
|
|
692
859
|
// Add the transaction to the collection's transactions store
|
|
693
|
-
this.transactions.
|
|
694
|
-
|
|
695
|
-
return sortedMap
|
|
696
|
-
})
|
|
860
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
861
|
+
this.recomputeOptimisticState()
|
|
697
862
|
|
|
698
863
|
return directOpTransaction
|
|
699
864
|
}
|
|
@@ -738,24 +903,38 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
738
903
|
* // Update with metadata
|
|
739
904
|
* update("todo-1", { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" })
|
|
740
905
|
*/
|
|
906
|
+
// Overload 1: Update multiple items with a callback
|
|
741
907
|
update<TItem extends object = T>(
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
maybeCallback?: (draft: TItem) => void
|
|
908
|
+
key: Array<TKey | unknown>,
|
|
909
|
+
callback: (drafts: Array<TItem>) => void
|
|
745
910
|
): TransactionType
|
|
746
911
|
|
|
912
|
+
// Overload 2: Update multiple items with config and a callback
|
|
747
913
|
update<TItem extends object = T>(
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
914
|
+
keys: Array<TKey | unknown>,
|
|
915
|
+
config: OperationConfig,
|
|
916
|
+
callback: (drafts: Array<TItem>) => void
|
|
751
917
|
): TransactionType
|
|
752
918
|
|
|
919
|
+
// Overload 3: Update a single item with a callback
|
|
753
920
|
update<TItem extends object = T>(
|
|
754
|
-
|
|
921
|
+
id: TKey | unknown,
|
|
922
|
+
callback: (draft: TItem) => void
|
|
923
|
+
): TransactionType
|
|
924
|
+
|
|
925
|
+
// Overload 4: Update a single item with config and a callback
|
|
926
|
+
update<TItem extends object = T>(
|
|
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>,
|
|
755
934
|
configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig,
|
|
756
935
|
maybeCallback?: (draft: TItem | Array<TItem>) => void
|
|
757
936
|
) {
|
|
758
|
-
if (typeof
|
|
937
|
+
if (typeof keys === `undefined`) {
|
|
759
938
|
throw new Error(`The first argument to update is missing`)
|
|
760
939
|
}
|
|
761
940
|
|
|
@@ -768,21 +947,24 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
768
947
|
)
|
|
769
948
|
}
|
|
770
949
|
|
|
771
|
-
const isArray = Array.isArray(
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
)
|
|
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
|
+
|
|
775
957
|
const callback =
|
|
776
958
|
typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
|
|
777
959
|
const config =
|
|
778
960
|
typeof configOrCallback === `function` ? {} : configOrCallback
|
|
779
961
|
|
|
780
962
|
// Get the current objects or empty objects if they don't exist
|
|
781
|
-
const currentObjects =
|
|
782
|
-
const item = this.
|
|
963
|
+
const currentObjects = keysArray.map((key) => {
|
|
964
|
+
const item = this.get(key)
|
|
783
965
|
if (!item) {
|
|
784
966
|
throw new Error(
|
|
785
|
-
`The
|
|
967
|
+
`The key "${key}" was passed to update but an object for this key was not found in the collection`
|
|
786
968
|
)
|
|
787
969
|
}
|
|
788
970
|
|
|
@@ -798,15 +980,15 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
798
980
|
)
|
|
799
981
|
} else {
|
|
800
982
|
const result = withChangeTracking(
|
|
801
|
-
currentObjects[0]
|
|
983
|
+
currentObjects[0]!,
|
|
802
984
|
callback as (draft: TItem) => void
|
|
803
985
|
)
|
|
804
986
|
changesArray = [result]
|
|
805
987
|
}
|
|
806
988
|
|
|
807
989
|
// Create mutations for each object that has changes
|
|
808
|
-
const mutations: Array<PendingMutation<T>> =
|
|
809
|
-
.map((
|
|
990
|
+
const mutations: Array<PendingMutation<T>> = keysArray
|
|
991
|
+
.map((key, index) => {
|
|
810
992
|
const itemChanges = changesArray[index] // User-provided changes for this specific item
|
|
811
993
|
|
|
812
994
|
// Skip items with no changes
|
|
@@ -819,30 +1001,37 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
819
1001
|
const validatedUpdatePayload = this.validateData(
|
|
820
1002
|
itemChanges,
|
|
821
1003
|
`update`,
|
|
822
|
-
|
|
1004
|
+
key
|
|
823
1005
|
)
|
|
824
1006
|
|
|
825
1007
|
// Construct the full modified item by applying the validated update payload to the original item
|
|
826
|
-
const modifiedItem =
|
|
1008
|
+
const modifiedItem = Object.assign(
|
|
1009
|
+
{},
|
|
1010
|
+
originalItem,
|
|
1011
|
+
validatedUpdatePayload
|
|
1012
|
+
)
|
|
827
1013
|
|
|
828
1014
|
// Check if the ID of the item is being changed
|
|
829
|
-
const originalItemId = this.
|
|
830
|
-
const modifiedItemId = this.
|
|
1015
|
+
const originalItemId = this.getKeyFromItem(originalItem)
|
|
1016
|
+
const modifiedItemId = this.getKeyFromItem(modifiedItem)
|
|
831
1017
|
|
|
832
1018
|
if (originalItemId !== modifiedItemId) {
|
|
833
1019
|
throw new Error(
|
|
834
|
-
`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.`
|
|
835
1021
|
)
|
|
836
1022
|
}
|
|
837
1023
|
|
|
1024
|
+
const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem)
|
|
1025
|
+
|
|
838
1026
|
return {
|
|
839
1027
|
mutationId: crypto.randomUUID(),
|
|
840
1028
|
original: originalItem as Record<string, unknown>,
|
|
841
1029
|
modified: modifiedItem as Record<string, unknown>,
|
|
842
1030
|
changes: validatedUpdatePayload as Record<string, unknown>,
|
|
843
|
-
|
|
1031
|
+
globalKey,
|
|
1032
|
+
key,
|
|
844
1033
|
metadata: config.metadata as unknown,
|
|
845
|
-
syncMetadata: (this.syncedMetadata.
|
|
1034
|
+
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
|
|
846
1035
|
string,
|
|
847
1036
|
unknown
|
|
848
1037
|
>,
|
|
@@ -863,10 +1052,8 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
863
1052
|
if (ambientTransaction) {
|
|
864
1053
|
ambientTransaction.applyMutations(mutations)
|
|
865
1054
|
|
|
866
|
-
this.transactions.
|
|
867
|
-
|
|
868
|
-
return sortedMap
|
|
869
|
-
})
|
|
1055
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
1056
|
+
this.recomputeOptimisticState()
|
|
870
1057
|
|
|
871
1058
|
return ambientTransaction
|
|
872
1059
|
}
|
|
@@ -874,10 +1061,10 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
874
1061
|
// No need to check for onUpdate handler here as we've already checked at the beginning
|
|
875
1062
|
|
|
876
1063
|
// Create a new transaction with a mutation function that calls the onUpdate handler
|
|
877
|
-
const directOpTransaction = new Transaction({
|
|
878
|
-
mutationFn: async (
|
|
1064
|
+
const directOpTransaction = new Transaction<T>({
|
|
1065
|
+
mutationFn: async (params) => {
|
|
879
1066
|
// Call the onUpdate handler with the transaction
|
|
880
|
-
return this.config.onUpdate!(
|
|
1067
|
+
return this.config.onUpdate!(params)
|
|
881
1068
|
},
|
|
882
1069
|
})
|
|
883
1070
|
|
|
@@ -886,10 +1073,9 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
886
1073
|
directOpTransaction.commit()
|
|
887
1074
|
|
|
888
1075
|
// Add the transaction to the collection's transactions store
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
})
|
|
1076
|
+
|
|
1077
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
1078
|
+
this.recomputeOptimisticState()
|
|
893
1079
|
|
|
894
1080
|
return directOpTransaction
|
|
895
1081
|
}
|
|
@@ -910,9 +1096,9 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
910
1096
|
* delete("todo-1", { metadata: { reason: "completed" } })
|
|
911
1097
|
*/
|
|
912
1098
|
delete = (
|
|
913
|
-
|
|
1099
|
+
keys: Array<TKey> | TKey,
|
|
914
1100
|
config?: OperationConfig
|
|
915
|
-
): TransactionType => {
|
|
1101
|
+
): TransactionType<any> => {
|
|
916
1102
|
const ambientTransaction = getActiveTransaction()
|
|
917
1103
|
|
|
918
1104
|
// If no ambient transaction exists, check for an onDelete handler early
|
|
@@ -922,20 +1108,24 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
922
1108
|
)
|
|
923
1109
|
}
|
|
924
1110
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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]
|
|
928
1116
|
const mutations: Array<PendingMutation<T>> = []
|
|
929
1117
|
|
|
930
|
-
for (const
|
|
1118
|
+
for (const key of keysArray) {
|
|
1119
|
+
const globalKey = this.generateGlobalKey(key, this.get(key)!)
|
|
931
1120
|
const mutation: PendingMutation<T> = {
|
|
932
1121
|
mutationId: crypto.randomUUID(),
|
|
933
|
-
original:
|
|
934
|
-
modified:
|
|
935
|
-
changes:
|
|
936
|
-
|
|
1122
|
+
original: this.get(key) || {},
|
|
1123
|
+
modified: this.get(key)!,
|
|
1124
|
+
changes: this.get(key) || {},
|
|
1125
|
+
globalKey,
|
|
1126
|
+
key,
|
|
937
1127
|
metadata: config?.metadata as unknown,
|
|
938
|
-
syncMetadata: (this.syncedMetadata.
|
|
1128
|
+
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
|
|
939
1129
|
string,
|
|
940
1130
|
unknown
|
|
941
1131
|
>,
|
|
@@ -952,20 +1142,18 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
952
1142
|
if (ambientTransaction) {
|
|
953
1143
|
ambientTransaction.applyMutations(mutations)
|
|
954
1144
|
|
|
955
|
-
this.transactions.
|
|
956
|
-
|
|
957
|
-
return sortedMap
|
|
958
|
-
})
|
|
1145
|
+
this.transactions.set(ambientTransaction.id, ambientTransaction)
|
|
1146
|
+
this.recomputeOptimisticState()
|
|
959
1147
|
|
|
960
1148
|
return ambientTransaction
|
|
961
1149
|
}
|
|
962
1150
|
|
|
963
1151
|
// Create a new transaction with a mutation function that calls the onDelete handler
|
|
964
|
-
const directOpTransaction = new Transaction({
|
|
1152
|
+
const directOpTransaction = new Transaction<T>({
|
|
965
1153
|
autoCommit: true,
|
|
966
|
-
mutationFn: async (
|
|
1154
|
+
mutationFn: async (params) => {
|
|
967
1155
|
// Call the onDelete handler with the transaction
|
|
968
|
-
return this.config.onDelete!(
|
|
1156
|
+
return this.config.onDelete!(params)
|
|
969
1157
|
},
|
|
970
1158
|
})
|
|
971
1159
|
|
|
@@ -973,11 +1161,8 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
973
1161
|
directOpTransaction.applyMutations(mutations)
|
|
974
1162
|
directOpTransaction.commit()
|
|
975
1163
|
|
|
976
|
-
|
|
977
|
-
this.
|
|
978
|
-
sortedMap.set(directOpTransaction.id, directOpTransaction)
|
|
979
|
-
return sortedMap
|
|
980
|
-
})
|
|
1164
|
+
this.transactions.set(directOpTransaction.id, directOpTransaction)
|
|
1165
|
+
this.recomputeOptimisticState()
|
|
981
1166
|
|
|
982
1167
|
return directOpTransaction
|
|
983
1168
|
}
|
|
@@ -988,7 +1173,11 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
988
1173
|
* @returns A Map containing all items in the collection, with keys as identifiers
|
|
989
1174
|
*/
|
|
990
1175
|
get state() {
|
|
991
|
-
|
|
1176
|
+
const result = new Map<TKey, T>()
|
|
1177
|
+
for (const [key, value] of this.entries()) {
|
|
1178
|
+
result.set(key, value)
|
|
1179
|
+
}
|
|
1180
|
+
return result
|
|
992
1181
|
}
|
|
993
1182
|
|
|
994
1183
|
/**
|
|
@@ -997,14 +1186,14 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
997
1186
|
*
|
|
998
1187
|
* @returns Promise that resolves to a Map containing all items in the collection
|
|
999
1188
|
*/
|
|
1000
|
-
stateWhenReady(): Promise<Map<
|
|
1189
|
+
stateWhenReady(): Promise<Map<TKey, T>> {
|
|
1001
1190
|
// If we already have data or there are no loading collections, resolve immediately
|
|
1002
|
-
if (this.
|
|
1191
|
+
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
|
|
1003
1192
|
return Promise.resolve(this.state)
|
|
1004
1193
|
}
|
|
1005
1194
|
|
|
1006
1195
|
// Otherwise, wait for the first commit
|
|
1007
|
-
return new Promise<Map<
|
|
1196
|
+
return new Promise<Map<TKey, T>>((resolve) => {
|
|
1008
1197
|
this.onFirstCommit(() => {
|
|
1009
1198
|
resolve(this.state)
|
|
1010
1199
|
})
|
|
@@ -1017,7 +1206,19 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
1017
1206
|
* @returns An Array containing all items in the collection
|
|
1018
1207
|
*/
|
|
1019
1208
|
get toArray() {
|
|
1020
|
-
|
|
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
|
|
1021
1222
|
}
|
|
1022
1223
|
|
|
1023
1224
|
/**
|
|
@@ -1028,7 +1229,7 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
1028
1229
|
*/
|
|
1029
1230
|
toArrayWhenReady(): Promise<Array<T>> {
|
|
1030
1231
|
// If we already have data or there are no loading collections, resolve immediately
|
|
1031
|
-
if (this.
|
|
1232
|
+
if (this.size > 0 || this.hasReceivedFirstCommit === true) {
|
|
1032
1233
|
return Promise.resolve(this.toArray)
|
|
1033
1234
|
}
|
|
1034
1235
|
|
|
@@ -1045,7 +1246,7 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
1045
1246
|
* @returns An array of changes
|
|
1046
1247
|
*/
|
|
1047
1248
|
public currentStateAsChanges(): Array<ChangeMessage<T>> {
|
|
1048
|
-
return
|
|
1249
|
+
return Array.from(this.entries()).map(([key, value]) => ({
|
|
1049
1250
|
type: `insert`,
|
|
1050
1251
|
key,
|
|
1051
1252
|
value,
|
|
@@ -1058,16 +1259,99 @@ export class Collection<T extends object = Record<string, unknown>> {
|
|
|
1058
1259
|
* @returns A function that can be called to unsubscribe from the changes
|
|
1059
1260
|
*/
|
|
1060
1261
|
public subscribeChanges(
|
|
1061
|
-
callback: (changes: Array<ChangeMessage<T>>) => void
|
|
1262
|
+
callback: (changes: Array<ChangeMessage<T>>) => void,
|
|
1263
|
+
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1264
|
+
): () => void {
|
|
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)
|
|
1272
|
+
|
|
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 } = {}
|
|
1062
1285
|
): () => void {
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
+
}
|
|
1065
1300
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
+
}
|
|
1070
1310
|
}
|
|
1071
|
-
}
|
|
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
|
|
1072
1356
|
}
|
|
1073
1357
|
}
|