@tanstack/db 0.0.7 → 0.0.8

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