@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.
Files changed (43) hide show
  1. package/dist/cjs/collection.cjs +452 -286
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +115 -26
  4. package/dist/cjs/index.cjs +1 -1
  5. package/dist/cjs/index.d.cts +1 -1
  6. package/dist/cjs/proxy.cjs +2 -2
  7. package/dist/cjs/proxy.cjs.map +1 -1
  8. package/dist/cjs/query/compiled-query.cjs +24 -38
  9. package/dist/cjs/query/compiled-query.cjs.map +1 -1
  10. package/dist/cjs/query/compiled-query.d.cts +2 -2
  11. package/dist/cjs/query/order-by.cjs +41 -38
  12. package/dist/cjs/query/order-by.cjs.map +1 -1
  13. package/dist/cjs/query/schema.d.cts +3 -3
  14. package/dist/cjs/transactions.cjs +7 -6
  15. package/dist/cjs/transactions.cjs.map +1 -1
  16. package/dist/cjs/transactions.d.cts +9 -9
  17. package/dist/cjs/types.d.cts +36 -22
  18. package/dist/esm/collection.d.ts +115 -26
  19. package/dist/esm/collection.js +453 -287
  20. package/dist/esm/collection.js.map +1 -1
  21. package/dist/esm/index.d.ts +1 -1
  22. package/dist/esm/index.js +2 -2
  23. package/dist/esm/proxy.js +2 -2
  24. package/dist/esm/proxy.js.map +1 -1
  25. package/dist/esm/query/compiled-query.d.ts +2 -2
  26. package/dist/esm/query/compiled-query.js +25 -39
  27. package/dist/esm/query/compiled-query.js.map +1 -1
  28. package/dist/esm/query/order-by.js +41 -38
  29. package/dist/esm/query/order-by.js.map +1 -1
  30. package/dist/esm/query/schema.d.ts +3 -3
  31. package/dist/esm/transactions.d.ts +9 -9
  32. package/dist/esm/transactions.js +7 -6
  33. package/dist/esm/transactions.js.map +1 -1
  34. package/dist/esm/types.d.ts +36 -22
  35. package/package.json +2 -2
  36. package/src/collection.ts +652 -368
  37. package/src/index.ts +1 -1
  38. package/src/proxy.ts +2 -2
  39. package/src/query/compiled-query.ts +29 -39
  40. package/src/query/order-by.ts +69 -67
  41. package/src/query/schema.ts +3 -3
  42. package/src/transactions.ts +24 -22
  43. package/src/types.ts +54 -22
package/src/collection.ts CHANGED
@@ -1,26 +1,31 @@
1
- import { Derived, Store, batch } from "@tanstack/store"
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 using Tanstack store
17
- export const collectionsStore = new Store(new Map<string, Collection<any>>())
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
- Promise<Collection<Record<string, unknown>>>
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
- * @param config - Configuration for the collection, including id and sync
36
- * @returns A new Collection instance
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<T extends object = Record<string, unknown>>(
39
- config: CollectionConfig<T>
40
- ): Collection<T> {
41
- return new Collection<T>(config)
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<T extends object = Record<string, unknown>>(
71
- config: CollectionConfig<T>
72
- ): Promise<Collection<T>> {
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.state.has(config.id) &&
80
- !loadingCollections.has(config.id)
113
+ collectionsStore.has(config.id) &&
114
+ !loadingCollectionResolvers.has(config.id)
81
115
  ) {
82
116
  return Promise.resolve(
83
- collectionsStore.state.get(config.id)! as Collection<T>
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 (loadingCollections.has(config.id)) {
89
- return loadingCollections.get(config.id)! as Promise<Collection<T>>
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.state.has(config.id)) {
94
- collectionsStore.setState((prev) => {
95
- const next = new Map(prev)
96
- if (!config.id) {
97
- throw new Error(`The id property is required for preloadCollection`)
98
- }
99
- next.set(
100
- config.id,
101
- new Collection<T>({
102
- id: config.id,
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.state.get(config.id)! as Collection<T>
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<Collection<T>>((resolve) => {
117
- resolveFirstCommit = () => {
118
- resolve(collection)
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 (loadingCollections.has(config.id)) {
128
- loadingCollections.delete(config.id)
129
- resolveFirstCommit()
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 Collection<T extends object = Record<string, unknown>> {
172
- public transactions: Store<SortedMap<string, TransactionType>>
173
- public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
174
- public derivedState: Derived<Map<string, T>>
175
- public derivedArray: Derived<Array<T>>
176
- public derivedChanges: Derived<Array<ChangeMessage<T>>>
177
- public syncedData = new Store<Map<string, T>>(new Map())
178
- public syncedMetadata = new Store(new Map<string, unknown>())
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<string>()
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 Store(
221
- new SortedMap<string, TransactionType>(
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.generateObjectKey(
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 ID already exists when inserting
293
+ // Check if an item with this key already exists when inserting
398
294
  if (messageWithoutKey.type === `insert`) {
399
295
  if (
400
- this.syncedData.state.has(key) &&
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 ID "${id}" from sync because it already exists in the collection "${this.id}"`
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.state.values()).some(
578
+ !Array.from(this.transactions.values()).some(
446
579
  ({ state }) => state === `persisting`
447
580
  )
448
581
  ) {
449
- batch(() => {
450
- for (const transaction of this.pendingSyncedTransactions) {
451
- for (const operation of transaction.operations) {
452
- this.syncedKeys.add(operation.key)
453
- this.syncedMetadata.setState((prevData) => {
454
- switch (operation.type) {
455
- case `insert`:
456
- prevData.set(operation.key, operation.metadata)
457
- break
458
- case `update`:
459
- prevData.set(operation.key, {
460
- ...prevData.get(operation.key)!,
461
- ...operation.metadata,
462
- })
463
- break
464
- case `delete`:
465
- prevData.delete(operation.key)
466
- break
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
- return prevData
469
- })
470
- this.syncedData.setState((prevData) => {
471
- switch (operation.type) {
472
- case `insert`:
473
- prevData.set(operation.key, operation.value)
474
- break
475
- case `update`:
476
- prevData.set(operation.key, {
477
- ...prevData.get(operation.key)!,
478
- ...operation.value,
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
- break
481
- case `delete`:
482
- prevData.delete(operation.key)
483
- break
660
+ }
484
661
  }
485
- return prevData
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
- private getKeyFromId(id: unknown): string {
515
- if (typeof id === `undefined`) {
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 generateObjectKey(id: any, item: any): string {
528
- if (typeof id === `undefined`) {
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 id: ${JSON.stringify(item)}`
703
+ `An object was created without a defined key: ${JSON.stringify(item)}`
531
704
  )
532
705
  }
533
706
 
534
- return `KEY::${this.id}/${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?: string
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.state.get(key)
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 = { ...existingData, ...data }
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, index) => {
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 id = this.config.getId(item)
648
- if (this.state.has(this.getKeyFromId(id))) {
649
- throw `Cannot insert document with ID "${id}" because it already exists in the collection`
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 as Record<string, unknown>,
656
- changes: validatedData as Record<string, unknown>,
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.setState((sortedMap) => {
674
- sortedMap.set(ambientTransaction.id, ambientTransaction)
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.setState((sortedMap) => {
694
- sortedMap.set(directOpTransaction.id, directOpTransaction)
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
- id: unknown,
743
- configOrCallback: ((draft: TItem) => void) | OperationConfig,
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
- ids: Array<unknown>,
749
- configOrCallback: ((draft: Array<TItem>) => void) | OperationConfig,
750
- maybeCallback?: (draft: Array<TItem>) => void
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
- ids: unknown | Array<unknown>,
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 ids === `undefined`) {
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(ids)
772
- const idsArray = (Array.isArray(ids) ? ids : [ids]).map((id) =>
773
- this.getKeyFromId(id)
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 = idsArray.map((id) => {
782
- const item = this.state.get(id)
963
+ const currentObjects = keysArray.map((key) => {
964
+ const item = this.get(key)
783
965
  if (!item) {
784
966
  throw new Error(
785
- `The id "${id}" was passed to update but an object for this ID was not found in the collection`
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] as TItem,
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>> = idsArray
809
- .map((id, index) => {
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
- id
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 = { ...originalItem, ...validatedUpdatePayload }
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.config.getId(originalItem)
830
- const modifiedItemId = this.config.getId(modifiedItem)
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 ID of an item is not allowed. Original ID: "${originalItemId}", Attempted new ID: "${modifiedItemId}". Please delete the old item and create a new one if an ID change is necessary.`
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
- key: id,
1031
+ globalKey,
1032
+ key,
844
1033
  metadata: config.metadata as unknown,
845
- syncMetadata: (this.syncedMetadata.state.get(id) || {}) as Record<
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.setState((sortedMap) => {
867
- sortedMap.set(ambientTransaction.id, ambientTransaction)
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 (transaction) => {
1064
+ const directOpTransaction = new Transaction<T>({
1065
+ mutationFn: async (params) => {
879
1066
  // Call the onUpdate handler with the transaction
880
- return this.config.onUpdate!(transaction)
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
- this.transactions.setState((sortedMap) => {
890
- sortedMap.set(directOpTransaction.id, directOpTransaction)
891
- return sortedMap
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
- ids: Array<string> | string,
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
- const idsArray = (Array.isArray(ids) ? ids : [ids]).map((id) =>
926
- this.getKeyFromId(id)
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 id of idsArray) {
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: (this.state.get(id) || {}) as Record<string, unknown>,
934
- modified: (this.state.get(id) || {}) as Record<string, unknown>,
935
- changes: (this.state.get(id) || {}) as Record<string, unknown>,
936
- key: id,
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.state.get(id) || {}) as Record<
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.setState((sortedMap) => {
956
- sortedMap.set(ambientTransaction.id, ambientTransaction)
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 (transaction) => {
1154
+ mutationFn: async (params) => {
967
1155
  // Call the onDelete handler with the transaction
968
- return this.config.onDelete!(transaction)
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
- // Add the transaction to the collection's transactions store
977
- this.transactions.setState((sortedMap) => {
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
- return this.derivedState.state
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<string, T>> {
1189
+ stateWhenReady(): Promise<Map<TKey, T>> {
1001
1190
  // If we already have data or there are no loading collections, resolve immediately
1002
- if (this.state.size > 0 || this.hasReceivedFirstCommit === true) {
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<string, T>>((resolve) => {
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
- return this.derivedArray.state
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.toArray.length > 0 || this.hasReceivedFirstCommit === true) {
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 [...this.state.entries()].map(([key, value]) => ({
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
- // First send the current state as changes
1064
- callback(this.currentStateAsChanges())
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
- // Then subscribe to changes, this returns an unsubscribe function
1067
- return this.derivedChanges.subscribe((changes) => {
1068
- if (changes.currentVal.length > 0) {
1069
- callback(changes.currentVal)
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
  }