@tanstack/db 0.0.11 → 0.0.13

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 (47) hide show
  1. package/dist/cjs/SortedMap.cjs +38 -11
  2. package/dist/cjs/SortedMap.cjs.map +1 -1
  3. package/dist/cjs/SortedMap.d.cts +10 -0
  4. package/dist/cjs/collection.cjs +476 -144
  5. package/dist/cjs/collection.cjs.map +1 -1
  6. package/dist/cjs/collection.d.cts +107 -32
  7. package/dist/cjs/index.cjs +2 -1
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/index.d.cts +1 -0
  10. package/dist/cjs/optimistic-action.cjs +21 -0
  11. package/dist/cjs/optimistic-action.cjs.map +1 -0
  12. package/dist/cjs/optimistic-action.d.cts +39 -0
  13. package/dist/cjs/query/compiled-query.cjs +38 -16
  14. package/dist/cjs/query/compiled-query.cjs.map +1 -1
  15. package/dist/cjs/query/query-builder.cjs +2 -2
  16. package/dist/cjs/query/query-builder.cjs.map +1 -1
  17. package/dist/cjs/transactions.cjs +3 -1
  18. package/dist/cjs/transactions.cjs.map +1 -1
  19. package/dist/cjs/types.d.cts +83 -10
  20. package/dist/esm/SortedMap.d.ts +10 -0
  21. package/dist/esm/SortedMap.js +38 -11
  22. package/dist/esm/SortedMap.js.map +1 -1
  23. package/dist/esm/collection.d.ts +107 -32
  24. package/dist/esm/collection.js +477 -145
  25. package/dist/esm/collection.js.map +1 -1
  26. package/dist/esm/index.d.ts +1 -0
  27. package/dist/esm/index.js +3 -2
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/optimistic-action.d.ts +39 -0
  30. package/dist/esm/optimistic-action.js +21 -0
  31. package/dist/esm/optimistic-action.js.map +1 -0
  32. package/dist/esm/query/compiled-query.js +38 -16
  33. package/dist/esm/query/compiled-query.js.map +1 -1
  34. package/dist/esm/query/query-builder.js +2 -2
  35. package/dist/esm/query/query-builder.js.map +1 -1
  36. package/dist/esm/transactions.js +3 -1
  37. package/dist/esm/transactions.js.map +1 -1
  38. package/dist/esm/types.d.ts +83 -10
  39. package/package.json +1 -1
  40. package/src/SortedMap.ts +46 -13
  41. package/src/collection.ts +689 -239
  42. package/src/index.ts +1 -0
  43. package/src/optimistic-action.ts +65 -0
  44. package/src/query/compiled-query.ts +79 -21
  45. package/src/query/query-builder.ts +2 -2
  46. package/src/transactions.ts +6 -1
  47. package/src/types.ts +124 -8
package/src/collection.ts CHANGED
@@ -6,28 +6,22 @@ import type {
6
6
  ChangeListener,
7
7
  ChangeMessage,
8
8
  CollectionConfig,
9
+ CollectionStatus,
9
10
  Fn,
10
11
  InsertConfig,
11
12
  OperationConfig,
12
13
  OptimisticChangeMessage,
13
14
  PendingMutation,
15
+ ResolveType,
14
16
  StandardSchema,
15
17
  Transaction as TransactionType,
16
18
  UtilsRecord,
17
19
  } from "./types"
20
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
18
21
 
19
22
  // Store collections in memory
20
23
  export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
21
24
 
22
- // Map to track loading collections
23
- const loadingCollectionResolvers = new Map<
24
- string,
25
- {
26
- promise: Promise<CollectionImpl<any, any>>
27
- resolve: (value: CollectionImpl<any, any>) => void
28
- }
29
- >()
30
-
31
25
  interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
32
26
  committed: boolean
33
27
  operations: Array<OptimisticChangeMessage<T>>
@@ -36,6 +30,7 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
36
30
  /**
37
31
  * Enhanced Collection interface that includes both data type T and utilities TUtils
38
32
  * @template T - The type of items in the collection
33
+ * @template TKey - The type of the key for the collection
39
34
  * @template TUtils - The utilities record type
40
35
  */
41
36
  export interface Collection<
@@ -49,20 +44,53 @@ export interface Collection<
49
44
  /**
50
45
  * Creates a new Collection instance with the given configuration
51
46
  *
52
- * @template T - The type of items in the collection
47
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
53
48
  * @template TKey - The type of the key for the collection
54
49
  * @template TUtils - The utilities record type
50
+ * @template TSchema - The schema type for validation and type inference (second priority)
51
+ * @template TFallback - The fallback type if no explicit or schema type is provided
55
52
  * @param options - Collection options with optional utilities
56
53
  * @returns A new Collection with utilities exposed both at top level and under .utils
54
+ *
55
+ * @example
56
+ * // Using explicit type
57
+ * const todos = createCollection<Todo>({
58
+ * getKey: (todo) => todo.id,
59
+ * sync: { sync: () => {} }
60
+ * })
61
+ *
62
+ * // Using schema for type inference (preferred as it also gives you client side validation)
63
+ * const todoSchema = z.object({
64
+ * id: z.string(),
65
+ * title: z.string(),
66
+ * completed: z.boolean()
67
+ * })
68
+ *
69
+ * const todos = createCollection({
70
+ * schema: todoSchema,
71
+ * getKey: (todo) => todo.id,
72
+ * sync: { sync: () => {} }
73
+ * })
74
+ *
75
+ * // Note: You must provide either an explicit type or a schema, but not both
57
76
  */
58
77
  export function createCollection<
59
- T extends object = Record<string, unknown>,
78
+ TExplicit = unknown,
60
79
  TKey extends string | number = string | number,
61
80
  TUtils extends UtilsRecord = {},
81
+ TSchema extends StandardSchemaV1 = StandardSchemaV1,
82
+ TFallback extends object = Record<string, unknown>,
62
83
  >(
63
- options: CollectionConfig<T, TKey> & { utils?: TUtils }
64
- ): Collection<T, TKey, TUtils> {
65
- const collection = new CollectionImpl<T, TKey>(options)
84
+ options: CollectionConfig<
85
+ ResolveType<TExplicit, TSchema, TFallback>,
86
+ TKey,
87
+ TSchema
88
+ > & { utils?: TUtils }
89
+ ): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils> {
90
+ const collection = new CollectionImpl<
91
+ ResolveType<TExplicit, TSchema, TFallback>,
92
+ TKey
93
+ >(options)
66
94
 
67
95
  // Copy utils to both top level and .utils namespace
68
96
  if (options.utils) {
@@ -71,98 +99,11 @@ export function createCollection<
71
99
  collection.utils = {} as TUtils
72
100
  }
73
101
 
74
- return collection as Collection<T, TKey, TUtils>
75
- }
76
-
77
- /**
78
- * Preloads a collection with the given configuration
79
- * Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished)
80
- * If the collection has already loaded, it resolves immediately
81
- *
82
- * This function is useful in route loaders or similar pre-rendering scenarios where you want
83
- * to ensure data is available before a route transition completes. It uses the same shared collection
84
- * instance that will be used by useCollection, ensuring data consistency.
85
- *
86
- * @example
87
- * ```typescript
88
- * // In a route loader
89
- * async function loader({ params }) {
90
- * await preloadCollection({
91
- * id: `users-${params.userId}`,
92
- * sync: { ... },
93
- * });
94
- *
95
- * return null;
96
- * }
97
- * ```
98
- *
99
- * @template T - The type of items in the collection
100
- * @param config - Configuration for the collection, including id and sync
101
- * @returns Promise that resolves when the initial sync is finished
102
- */
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>> {
107
- if (!config.id) {
108
- throw new Error(`The id property is required for preloadCollection`)
109
- }
110
-
111
- // If the collection is already fully loaded, return a resolved promise
112
- if (
113
- collectionsStore.has(config.id) &&
114
- !loadingCollectionResolvers.has(config.id)
115
- ) {
116
- return Promise.resolve(
117
- collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
118
- )
119
- }
120
-
121
- // If the collection is in the process of loading, return its promise
122
- if (loadingCollectionResolvers.has(config.id)) {
123
- return loadingCollectionResolvers.get(config.id)!.promise
124
- }
125
-
126
- // Create a new collection instance if it doesn't exist
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
- }
138
-
139
- const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
140
-
141
- // Create a promise that will resolve after the first commit
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!,
151
- })
152
-
153
- // Register a one-time listener for the first commit
154
- collection.onFirstCommit(() => {
155
- if (!config.id) {
156
- throw new Error(`The id property is required for preloadCollection`)
157
- }
158
- if (loadingCollectionResolvers.has(config.id)) {
159
- const resolver = loadingCollectionResolvers.get(config.id)!
160
- loadingCollectionResolvers.delete(config.id)
161
- resolver.resolve(collection)
162
- }
163
- })
164
-
165
- return firstCommitPromise
102
+ return collection as Collection<
103
+ ResolveType<TExplicit, TSchema, TFallback>,
104
+ TKey,
105
+ TUtils
106
+ >
166
107
  }
167
108
 
168
109
  /**
@@ -184,8 +125,8 @@ export class SchemaValidationError extends Error {
184
125
  message?: string
185
126
  ) {
186
127
  const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues
187
- .map((issue) => issue.message)
188
- .join(`, `)}`
128
+ .map((issue) => `\n- ${issue.message} - path: ${issue.path}`)
129
+ .join(``)}`
189
130
 
190
131
  super(message || defaultMessage)
191
132
  this.name = `SchemaValidationError`
@@ -198,10 +139,12 @@ export class CollectionImpl<
198
139
  T extends object = Record<string, unknown>,
199
140
  TKey extends string | number = string | number,
200
141
  > {
201
- public transactions: SortedMap<string, Transaction<any>>
142
+ public config: CollectionConfig<T, TKey, any>
202
143
 
203
144
  // Core state - make public for testing
204
- public syncedData = new Map<TKey, T>()
145
+ public transactions: SortedMap<string, Transaction<any>>
146
+ public pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
147
+ public syncedData: Map<TKey, T> | SortedMap<TKey, T>
205
148
  public syncedMetadata = new Map<TKey, unknown>()
206
149
 
207
150
  // Optimistic state tracking - make public for testing
@@ -219,14 +162,23 @@ export class CollectionImpl<
219
162
  // This is populated by createCollection
220
163
  public utils: Record<string, Fn> = {}
221
164
 
222
- private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
165
+ // State used for computing the change events
223
166
  private syncedKeys = new Set<TKey>()
224
- public config: CollectionConfig<T, TKey>
167
+ private preSyncVisibleState = new Map<TKey, T>()
168
+ private recentlySyncedKeys = new Set<TKey>()
225
169
  private hasReceivedFirstCommit = false
170
+ private isCommittingSyncTransactions = false
226
171
 
227
172
  // Array to store one-time commit listeners
228
173
  private onFirstCommitCallbacks: Array<() => void> = []
229
174
 
175
+ // Lifecycle management
176
+ private _status: CollectionStatus = `idle`
177
+ private activeSubscribersCount = 0
178
+ private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
179
+ private preloadPromise: Promise<void> | null = null
180
+ private syncCleanupFn: (() => void) | null = null
181
+
230
182
  /**
231
183
  * Register a callback to be executed on the next commit
232
184
  * Useful for preloading collections
@@ -238,13 +190,78 @@ export class CollectionImpl<
238
190
 
239
191
  public id = ``
240
192
 
193
+ /**
194
+ * Gets the current status of the collection
195
+ */
196
+ public get status(): CollectionStatus {
197
+ return this._status
198
+ }
199
+
200
+ /**
201
+ * Validates that the collection is in a usable state for data operations
202
+ * @private
203
+ */
204
+ private validateCollectionUsable(operation: string): void {
205
+ switch (this._status) {
206
+ case `error`:
207
+ throw new Error(
208
+ `Cannot perform ${operation} on collection "${this.id}" - collection is in error state. ` +
209
+ `Try calling cleanup() and restarting the collection.`
210
+ )
211
+ case `cleaned-up`:
212
+ throw new Error(
213
+ `Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. ` +
214
+ `The collection will automatically restart on next access.`
215
+ )
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Validates state transitions to prevent invalid status changes
221
+ * @private
222
+ */
223
+ private validateStatusTransition(
224
+ from: CollectionStatus,
225
+ to: CollectionStatus
226
+ ): void {
227
+ if (from === to) {
228
+ // Allow same state transitions
229
+ return
230
+ }
231
+ const validTransitions: Record<
232
+ CollectionStatus,
233
+ Array<CollectionStatus>
234
+ > = {
235
+ idle: [`loading`, `error`, `cleaned-up`],
236
+ loading: [`ready`, `error`, `cleaned-up`],
237
+ ready: [`cleaned-up`, `error`],
238
+ error: [`cleaned-up`, `idle`],
239
+ "cleaned-up": [`loading`, `error`],
240
+ }
241
+
242
+ if (!validTransitions[from].includes(to)) {
243
+ throw new Error(
244
+ `Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
245
+ )
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Safely update the collection status with validation
251
+ * @private
252
+ */
253
+ private setStatus(newStatus: CollectionStatus): void {
254
+ this.validateStatusTransition(this._status, newStatus)
255
+ this._status = newStatus
256
+ }
257
+
241
258
  /**
242
259
  * Creates a new Collection instance
243
260
  *
244
261
  * @param config - Configuration object for the collection
245
262
  * @throws Error if sync config is missing
246
263
  */
247
- constructor(config: CollectionConfig<T, TKey>) {
264
+ constructor(config: CollectionConfig<T, TKey, any>) {
248
265
  // eslint-disable-next-line
249
266
  if (!config) {
250
267
  throw new Error(`Collection requires a config`)
@@ -266,74 +283,276 @@ export class CollectionImpl<
266
283
 
267
284
  this.config = config
268
285
 
269
- // Start the sync process
270
- config.sync.sync({
271
- collection: this,
272
- begin: () => {
273
- this.pendingSyncedTransactions.push({
274
- committed: false,
275
- operations: [],
276
- })
277
- },
278
- write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
279
- const pendingTransaction =
280
- this.pendingSyncedTransactions[
281
- this.pendingSyncedTransactions.length - 1
282
- ]
283
- if (!pendingTransaction) {
284
- throw new Error(`No pending sync transaction to write to`)
285
- }
286
- if (pendingTransaction.committed) {
287
- throw new Error(
288
- `The pending sync transaction is already committed, you can't still write to it.`
289
- )
290
- }
291
- const key = this.getKeyFromItem(messageWithoutKey.value)
286
+ // Store in global collections store
287
+ collectionsStore.set(this.id, this)
292
288
 
293
- // Check if an item with this key already exists when inserting
294
- if (messageWithoutKey.type === `insert`) {
295
- if (
296
- this.syncedData.has(key) &&
297
- !pendingTransaction.operations.some(
298
- (op) => op.key === key && op.type === `delete`
289
+ // Set up data storage with optional comparison function
290
+ if (this.config.compare) {
291
+ this.syncedData = new SortedMap<TKey, T>(this.config.compare)
292
+ } else {
293
+ this.syncedData = new Map<TKey, T>()
294
+ }
295
+
296
+ // Only start sync immediately if explicitly enabled
297
+ if (config.startSync === true) {
298
+ this.startSync()
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Start sync immediately - internal method for compiled queries
304
+ * This bypasses lazy loading for special cases like live query results
305
+ */
306
+ public startSyncImmediate(): void {
307
+ this.startSync()
308
+ }
309
+
310
+ /**
311
+ * Start the sync process for this collection
312
+ * This is called when the collection is first accessed or preloaded
313
+ */
314
+ private startSync(): void {
315
+ if (this._status !== `idle` && this._status !== `cleaned-up`) {
316
+ return // Already started or in progress
317
+ }
318
+
319
+ this.setStatus(`loading`)
320
+
321
+ try {
322
+ const cleanupFn = this.config.sync.sync({
323
+ collection: this,
324
+ begin: () => {
325
+ this.pendingSyncedTransactions.push({
326
+ committed: false,
327
+ operations: [],
328
+ })
329
+ },
330
+ write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
331
+ const pendingTransaction =
332
+ this.pendingSyncedTransactions[
333
+ this.pendingSyncedTransactions.length - 1
334
+ ]
335
+ if (!pendingTransaction) {
336
+ throw new Error(`No pending sync transaction to write to`)
337
+ }
338
+ if (pendingTransaction.committed) {
339
+ throw new Error(
340
+ `The pending sync transaction is already committed, you can't still write to it.`
299
341
  )
300
- ) {
342
+ }
343
+ const key = this.getKeyFromItem(messageWithoutKey.value)
344
+
345
+ // Check if an item with this key already exists when inserting
346
+ if (messageWithoutKey.type === `insert`) {
347
+ if (
348
+ this.syncedData.has(key) &&
349
+ !pendingTransaction.operations.some(
350
+ (op) => op.key === key && op.type === `delete`
351
+ )
352
+ ) {
353
+ throw new Error(
354
+ `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
355
+ )
356
+ }
357
+ }
358
+
359
+ const message: ChangeMessage<T> = {
360
+ ...messageWithoutKey,
361
+ key,
362
+ }
363
+ pendingTransaction.operations.push(message)
364
+ },
365
+ commit: () => {
366
+ const pendingTransaction =
367
+ this.pendingSyncedTransactions[
368
+ this.pendingSyncedTransactions.length - 1
369
+ ]
370
+ if (!pendingTransaction) {
371
+ throw new Error(`No pending sync transaction to commit`)
372
+ }
373
+ if (pendingTransaction.committed) {
301
374
  throw new Error(
302
- `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
375
+ `The pending sync transaction is already committed, you can't commit it again.`
303
376
  )
304
377
  }
305
- }
306
378
 
307
- const message: ChangeMessage<T> = {
308
- ...messageWithoutKey,
309
- key,
310
- }
311
- pendingTransaction.operations.push(message)
312
- },
313
- commit: () => {
314
- const pendingTransaction =
315
- this.pendingSyncedTransactions[
316
- this.pendingSyncedTransactions.length - 1
317
- ]
318
- if (!pendingTransaction) {
319
- throw new Error(`No pending sync transaction to commit`)
379
+ pendingTransaction.committed = true
380
+ this.commitPendingTransactions()
381
+
382
+ // Update status to ready after first commit
383
+ if (this._status === `loading`) {
384
+ this.setStatus(`ready`)
385
+ }
386
+ },
387
+ })
388
+
389
+ // Store cleanup function if provided
390
+ this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
391
+ } catch (error) {
392
+ this.setStatus(`error`)
393
+ throw error
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Preload the collection data by starting sync if not already started
399
+ * Multiple concurrent calls will share the same promise
400
+ */
401
+ public preload(): Promise<void> {
402
+ if (this.preloadPromise) {
403
+ return this.preloadPromise
404
+ }
405
+
406
+ this.preloadPromise = new Promise<void>((resolve, reject) => {
407
+ if (this._status === `ready`) {
408
+ resolve()
409
+ return
410
+ }
411
+
412
+ if (this._status === `error`) {
413
+ reject(new Error(`Collection is in error state`))
414
+ return
415
+ }
416
+
417
+ // Register callback BEFORE starting sync to avoid race condition
418
+ this.onFirstCommit(() => {
419
+ resolve()
420
+ })
421
+
422
+ // Start sync if collection hasn't started yet or was cleaned up
423
+ if (this._status === `idle` || this._status === `cleaned-up`) {
424
+ try {
425
+ this.startSync()
426
+ } catch (error) {
427
+ reject(error)
428
+ return
320
429
  }
321
- if (pendingTransaction.committed) {
430
+ }
431
+ })
432
+
433
+ return this.preloadPromise
434
+ }
435
+
436
+ /**
437
+ * Clean up the collection by stopping sync and clearing data
438
+ * This can be called manually or automatically by garbage collection
439
+ */
440
+ public async cleanup(): Promise<void> {
441
+ // Clear GC timeout
442
+ if (this.gcTimeoutId) {
443
+ clearTimeout(this.gcTimeoutId)
444
+ this.gcTimeoutId = null
445
+ }
446
+
447
+ // Stop sync - wrap in try/catch since it's user-provided code
448
+ try {
449
+ if (this.syncCleanupFn) {
450
+ this.syncCleanupFn()
451
+ this.syncCleanupFn = null
452
+ }
453
+ } catch (error) {
454
+ // Re-throw in a microtask to surface the error after cleanup completes
455
+ queueMicrotask(() => {
456
+ if (error instanceof Error) {
457
+ // Preserve the original error and stack trace
458
+ const wrappedError = new Error(
459
+ `Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
460
+ )
461
+ wrappedError.cause = error
462
+ wrappedError.stack = error.stack
463
+ throw wrappedError
464
+ } else {
322
465
  throw new Error(
323
- `The pending sync transaction is already committed, you can't commit it again.`
466
+ `Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
324
467
  )
325
468
  }
469
+ })
470
+ }
326
471
 
327
- pendingTransaction.committed = true
328
- this.commitPendingTransactions()
329
- },
330
- })
472
+ // Clear data
473
+ this.syncedData.clear()
474
+ this.syncedMetadata.clear()
475
+ this.derivedUpserts.clear()
476
+ this.derivedDeletes.clear()
477
+ this._size = 0
478
+ this.pendingSyncedTransactions = []
479
+ this.syncedKeys.clear()
480
+ this.hasReceivedFirstCommit = false
481
+ this.onFirstCommitCallbacks = []
482
+ this.preloadPromise = null
483
+
484
+ // Update status
485
+ this.setStatus(`cleaned-up`)
486
+
487
+ return Promise.resolve()
488
+ }
489
+
490
+ /**
491
+ * Start the garbage collection timer
492
+ * Called when the collection becomes inactive (no subscribers)
493
+ */
494
+ private startGCTimer(): void {
495
+ if (this.gcTimeoutId) {
496
+ clearTimeout(this.gcTimeoutId)
497
+ }
498
+
499
+ const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
500
+ this.gcTimeoutId = setTimeout(() => {
501
+ if (this.activeSubscribersCount === 0) {
502
+ this.cleanup()
503
+ }
504
+ }, gcTime)
505
+ }
506
+
507
+ /**
508
+ * Cancel the garbage collection timer
509
+ * Called when the collection becomes active again
510
+ */
511
+ private cancelGCTimer(): void {
512
+ if (this.gcTimeoutId) {
513
+ clearTimeout(this.gcTimeoutId)
514
+ this.gcTimeoutId = null
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Increment the active subscribers count and start sync if needed
520
+ */
521
+ private addSubscriber(): void {
522
+ this.activeSubscribersCount++
523
+ this.cancelGCTimer()
524
+
525
+ // Start sync if collection was cleaned up
526
+ if (this._status === `cleaned-up` || this._status === `idle`) {
527
+ this.startSync()
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Decrement the active subscribers count and start GC timer if needed
533
+ */
534
+ private removeSubscriber(): void {
535
+ this.activeSubscribersCount--
536
+
537
+ if (this.activeSubscribersCount === 0) {
538
+ this.activeSubscribersCount = 0
539
+ this.startGCTimer()
540
+ } else if (this.activeSubscribersCount < 0) {
541
+ throw new Error(
542
+ `Active subscribers count is negative - this should never happen`
543
+ )
544
+ }
331
545
  }
332
546
 
333
547
  /**
334
548
  * Recompute optimistic state from active transactions
335
549
  */
336
550
  private recomputeOptimisticState(): void {
551
+ // Skip redundant recalculations when we're in the middle of committing sync transactions
552
+ if (this.isCommittingSyncTransactions) {
553
+ return
554
+ }
555
+
337
556
  const previousState = new Map(this.derivedUpserts)
338
557
  const previousDeletes = new Set(this.derivedDeletes)
339
558
 
@@ -341,23 +560,31 @@ export class CollectionImpl<
341
560
  this.derivedUpserts.clear()
342
561
  this.derivedDeletes.clear()
343
562
 
344
- // Apply active transactions
345
- const activeTransactions = Array.from(this.transactions.values())
563
+ const activeTransactions: Array<Transaction<any>> = []
564
+ const completedTransactions: Array<Transaction<any>> = []
565
+
566
+ for (const transaction of this.transactions.values()) {
567
+ if (transaction.state === `completed`) {
568
+ completedTransactions.push(transaction)
569
+ } else if (![`completed`, `failed`].includes(transaction.state)) {
570
+ activeTransactions.push(transaction)
571
+ }
572
+ }
573
+
574
+ // Apply active transactions only (completed transactions are handled by sync operations)
346
575
  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
- }
576
+ for (const mutation of transaction.mutations) {
577
+ if (mutation.collection === this) {
578
+ switch (mutation.type) {
579
+ case `insert`:
580
+ case `update`:
581
+ this.derivedUpserts.set(mutation.key, mutation.modified as T)
582
+ this.derivedDeletes.delete(mutation.key)
583
+ break
584
+ case `delete`:
585
+ this.derivedUpserts.delete(mutation.key)
586
+ this.derivedDeletes.add(mutation.key)
587
+ break
361
588
  }
362
589
  }
363
590
  }
@@ -370,8 +597,58 @@ export class CollectionImpl<
370
597
  const events: Array<ChangeMessage<T, TKey>> = []
371
598
  this.collectOptimisticChanges(previousState, previousDeletes, events)
372
599
 
373
- // Emit all events at once
374
- this.emitEvents(events)
600
+ // Filter out events for recently synced keys to prevent duplicates
601
+ const filteredEventsBySyncStatus = events.filter(
602
+ (event) => !this.recentlySyncedKeys.has(event.key)
603
+ )
604
+
605
+ // Filter out redundant delete events if there are pending sync transactions
606
+ // that will immediately restore the same data, but only for completed transactions
607
+ if (this.pendingSyncedTransactions.length > 0) {
608
+ const pendingSyncKeys = new Set<TKey>()
609
+ const completedTransactionMutations = new Set<string>()
610
+
611
+ // Collect keys from pending sync operations
612
+ for (const transaction of this.pendingSyncedTransactions) {
613
+ for (const operation of transaction.operations) {
614
+ pendingSyncKeys.add(operation.key as TKey)
615
+ }
616
+ }
617
+
618
+ // Collect mutation IDs from completed transactions
619
+ for (const tx of completedTransactions) {
620
+ for (const mutation of tx.mutations) {
621
+ if (mutation.collection === this) {
622
+ completedTransactionMutations.add(mutation.mutationId)
623
+ }
624
+ }
625
+ }
626
+
627
+ // Only filter out delete events for keys that:
628
+ // 1. Have pending sync operations AND
629
+ // 2. Are from completed transactions (being cleaned up)
630
+ const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
631
+ if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
632
+ // Check if this delete is from clearing optimistic state of completed transactions
633
+ // We can infer this by checking if we have no remaining optimistic mutations for this key
634
+ const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
635
+ tx.mutations.some(
636
+ (m) => m.collection === this && m.key === event.key
637
+ )
638
+ )
639
+
640
+ if (!hasActiveOptimisticMutation) {
641
+ return false // Skip this delete event as sync will restore the data
642
+ }
643
+ }
644
+ return true
645
+ })
646
+
647
+ this.emitEvents(filteredEvents)
648
+ } else {
649
+ // Emit all events if no pending sync transactions
650
+ this.emitEvents(filteredEventsBySyncStatus)
651
+ }
375
652
  }
376
653
 
377
654
  /**
@@ -552,7 +829,10 @@ export class CollectionImpl<
552
829
  for (const key of this.keys()) {
553
830
  const value = this.get(key)
554
831
  if (value !== undefined) {
555
- yield value
832
+ const { _orderByIndex, ...copy } = value as T & {
833
+ _orderByIndex?: number | string
834
+ }
835
+ yield copy as T
556
836
  }
557
837
  }
558
838
  }
@@ -564,7 +844,10 @@ export class CollectionImpl<
564
844
  for (const key of this.keys()) {
565
845
  const value = this.get(key)
566
846
  if (value !== undefined) {
567
- yield [key, value]
847
+ const { _orderByIndex, ...copy } = value as T & {
848
+ _orderByIndex?: number | string
849
+ }
850
+ yield [key, copy as T]
568
851
  }
569
852
  }
570
853
  }
@@ -574,18 +857,46 @@ export class CollectionImpl<
574
857
  * This method processes operations from pending transactions and applies them to the synced data
575
858
  */
576
859
  commitPendingTransactions = () => {
577
- if (
578
- !Array.from(this.transactions.values()).some(
579
- ({ state }) => state === `persisting`
580
- )
581
- ) {
860
+ // Check if there are any persisting transaction
861
+ let hasPersistingTransaction = false
862
+ for (const transaction of this.transactions.values()) {
863
+ if (transaction.state === `persisting`) {
864
+ hasPersistingTransaction = true
865
+ break
866
+ }
867
+ }
868
+
869
+ if (!hasPersistingTransaction) {
870
+ // Set flag to prevent redundant optimistic state recalculations
871
+ this.isCommittingSyncTransactions = true
872
+
873
+ // First collect all keys that will be affected by sync operations
582
874
  const changedKeys = new Set<TKey>()
875
+ for (const transaction of this.pendingSyncedTransactions) {
876
+ for (const operation of transaction.operations) {
877
+ changedKeys.add(operation.key as TKey)
878
+ }
879
+ }
880
+
881
+ // Use pre-captured state if available (from optimistic scenarios),
882
+ // otherwise capture current state (for pure sync scenarios)
883
+ let currentVisibleState = this.preSyncVisibleState
884
+ if (currentVisibleState.size === 0) {
885
+ // No pre-captured state, capture it now for pure sync operations
886
+ currentVisibleState = new Map<TKey, T>()
887
+ for (const key of changedKeys) {
888
+ const currentValue = this.get(key)
889
+ if (currentValue !== undefined) {
890
+ currentVisibleState.set(key, currentValue)
891
+ }
892
+ }
893
+ }
894
+
583
895
  const events: Array<ChangeMessage<T, TKey>> = []
584
896
 
585
897
  for (const transaction of this.pendingSyncedTransactions) {
586
898
  for (const operation of transaction.operations) {
587
899
  const key = operation.key as TKey
588
- changedKeys.add(key)
589
900
  this.syncedKeys.add(key)
590
901
 
591
902
  // Update metadata
@@ -608,22 +919,10 @@ export class CollectionImpl<
608
919
  break
609
920
  }
610
921
 
611
- // Update synced data and collect events
612
- const previousValue = this.syncedData.get(key)
613
-
922
+ // Update synced data
614
923
  switch (operation.type) {
615
924
  case `insert`:
616
925
  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
926
  break
628
927
  case `update`: {
629
928
  const updatedValue = Object.assign(
@@ -632,38 +931,103 @@ export class CollectionImpl<
632
931
  operation.value
633
932
  )
634
933
  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
- })
645
- }
646
934
  break
647
935
  }
648
936
  case `delete`:
649
937
  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,
659
- })
660
- }
661
- }
662
938
  break
663
939
  }
664
940
  }
665
941
  }
666
942
 
943
+ // Clear optimistic state since sync operations will now provide the authoritative data
944
+ this.derivedUpserts.clear()
945
+ this.derivedDeletes.clear()
946
+
947
+ // Reset flag and recompute optimistic state for any remaining active transactions
948
+ this.isCommittingSyncTransactions = false
949
+ for (const transaction of this.transactions.values()) {
950
+ if (![`completed`, `failed`].includes(transaction.state)) {
951
+ for (const mutation of transaction.mutations) {
952
+ if (mutation.collection === this) {
953
+ switch (mutation.type) {
954
+ case `insert`:
955
+ case `update`:
956
+ this.derivedUpserts.set(mutation.key, mutation.modified as T)
957
+ this.derivedDeletes.delete(mutation.key)
958
+ break
959
+ case `delete`:
960
+ this.derivedUpserts.delete(mutation.key)
961
+ this.derivedDeletes.add(mutation.key)
962
+ break
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+
969
+ // Check for redundant sync operations that match completed optimistic operations
970
+ const completedOptimisticOps = new Map<TKey, any>()
971
+
972
+ for (const transaction of this.transactions.values()) {
973
+ if (transaction.state === `completed`) {
974
+ for (const mutation of transaction.mutations) {
975
+ if (mutation.collection === this && changedKeys.has(mutation.key)) {
976
+ completedOptimisticOps.set(mutation.key, {
977
+ type: mutation.type,
978
+ value: mutation.modified,
979
+ })
980
+ }
981
+ }
982
+ }
983
+ }
984
+
985
+ // Now check what actually changed in the final visible state
986
+ for (const key of changedKeys) {
987
+ const previousVisibleValue = currentVisibleState.get(key)
988
+ const newVisibleValue = this.get(key) // This returns the new derived state
989
+
990
+ // Check if this sync operation is redundant with a completed optimistic operation
991
+ const completedOp = completedOptimisticOps.get(key)
992
+ const isRedundantSync =
993
+ completedOp &&
994
+ newVisibleValue !== undefined &&
995
+ this.deepEqual(completedOp.value, newVisibleValue)
996
+
997
+ if (!isRedundantSync) {
998
+ if (
999
+ previousVisibleValue === undefined &&
1000
+ newVisibleValue !== undefined
1001
+ ) {
1002
+ events.push({
1003
+ type: `insert`,
1004
+ key,
1005
+ value: newVisibleValue,
1006
+ })
1007
+ } else if (
1008
+ previousVisibleValue !== undefined &&
1009
+ newVisibleValue === undefined
1010
+ ) {
1011
+ events.push({
1012
+ type: `delete`,
1013
+ key,
1014
+ value: previousVisibleValue,
1015
+ })
1016
+ } else if (
1017
+ previousVisibleValue !== undefined &&
1018
+ newVisibleValue !== undefined &&
1019
+ !this.deepEqual(previousVisibleValue, newVisibleValue)
1020
+ ) {
1021
+ events.push({
1022
+ type: `update`,
1023
+ key,
1024
+ value: newVisibleValue,
1025
+ previousValue: previousVisibleValue,
1026
+ })
1027
+ }
1028
+ }
1029
+ }
1030
+
667
1031
  // Update cached size after synced data changes
668
1032
  this._size = this.calculateSize()
669
1033
 
@@ -672,6 +1036,14 @@ export class CollectionImpl<
672
1036
 
673
1037
  this.pendingSyncedTransactions = []
674
1038
 
1039
+ // Clear the pre-sync state since sync operations are complete
1040
+ this.preSyncVisibleState.clear()
1041
+
1042
+ // Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
1043
+ Promise.resolve().then(() => {
1044
+ this.recentlySyncedKeys.clear()
1045
+ })
1046
+
675
1047
  // Call any registered one-time commit listeners
676
1048
  if (!this.hasReceivedFirstCommit) {
677
1049
  this.hasReceivedFirstCommit = true
@@ -707,6 +1079,29 @@ export class CollectionImpl<
707
1079
  return `KEY::${this.id}/${key}`
708
1080
  }
709
1081
 
1082
+ private deepEqual(a: any, b: any): boolean {
1083
+ if (a === b) return true
1084
+ if (a == null || b == null) return false
1085
+ if (typeof a !== typeof b) return false
1086
+
1087
+ if (typeof a === `object`) {
1088
+ if (Array.isArray(a) !== Array.isArray(b)) return false
1089
+
1090
+ const keysA = Object.keys(a)
1091
+ const keysB = Object.keys(b)
1092
+ if (keysA.length !== keysB.length) return false
1093
+
1094
+ const keysBSet = new Set(keysB)
1095
+ for (const key of keysA) {
1096
+ if (!keysBSet.has(key)) return false
1097
+ if (!this.deepEqual(a[key], b[key])) return false
1098
+ }
1099
+ return true
1100
+ }
1101
+
1102
+ return false
1103
+ }
1104
+
710
1105
  private validateData(
711
1106
  data: unknown,
712
1107
  type: `insert` | `update`,
@@ -793,6 +1188,8 @@ export class CollectionImpl<
793
1188
  * insert({ text: "Buy groceries" }, { key: "grocery-task" })
794
1189
  */
795
1190
  insert = (data: T | Array<T>, config?: InsertConfig) => {
1191
+ this.validateCollectionUsable(`insert`)
1192
+
796
1193
  const ambientTransaction = getActiveTransaction()
797
1194
 
798
1195
  // If no ambient transaction exists, check for an onInsert handler early
@@ -803,7 +1200,7 @@ export class CollectionImpl<
803
1200
  }
804
1201
 
805
1202
  const items = Array.isArray(data) ? data : [data]
806
- const mutations: Array<PendingMutation<T>> = []
1203
+ const mutations: Array<PendingMutation<T, `insert`>> = []
807
1204
 
808
1205
  // Create mutations for each item
809
1206
  items.forEach((item) => {
@@ -817,7 +1214,7 @@ export class CollectionImpl<
817
1214
  }
818
1215
  const globalKey = this.generateGlobalKey(key, item)
819
1216
 
820
- const mutation: PendingMutation<T> = {
1217
+ const mutation: PendingMutation<T, `insert`> = {
821
1218
  mutationId: crypto.randomUUID(),
822
1219
  original: {},
823
1220
  modified: validatedData,
@@ -938,6 +1335,8 @@ export class CollectionImpl<
938
1335
  throw new Error(`The first argument to update is missing`)
939
1336
  }
940
1337
 
1338
+ this.validateCollectionUsable(`update`)
1339
+
941
1340
  const ambientTransaction = getActiveTransaction()
942
1341
 
943
1342
  // If no ambient transaction exists, check for an onUpdate handler early
@@ -987,7 +1386,7 @@ export class CollectionImpl<
987
1386
  }
988
1387
 
989
1388
  // Create mutations for each object that has changes
990
- const mutations: Array<PendingMutation<T>> = keysArray
1389
+ const mutations: Array<PendingMutation<T, `update`>> = keysArray
991
1390
  .map((key, index) => {
992
1391
  const itemChanges = changesArray[index] // User-provided changes for this specific item
993
1392
 
@@ -1025,9 +1424,9 @@ export class CollectionImpl<
1025
1424
 
1026
1425
  return {
1027
1426
  mutationId: crypto.randomUUID(),
1028
- original: originalItem as Record<string, unknown>,
1029
- modified: modifiedItem as Record<string, unknown>,
1030
- changes: validatedUpdatePayload as Record<string, unknown>,
1427
+ original: originalItem,
1428
+ modified: modifiedItem,
1429
+ changes: validatedUpdatePayload as Partial<T>,
1031
1430
  globalKey,
1032
1431
  key,
1033
1432
  metadata: config.metadata as unknown,
@@ -1041,7 +1440,7 @@ export class CollectionImpl<
1041
1440
  collection: this,
1042
1441
  }
1043
1442
  })
1044
- .filter(Boolean) as Array<PendingMutation<T>>
1443
+ .filter(Boolean) as Array<PendingMutation<T, `update`>>
1045
1444
 
1046
1445
  // If no changes were made, return an empty transaction early
1047
1446
  if (mutations.length === 0) {
@@ -1103,6 +1502,8 @@ export class CollectionImpl<
1103
1502
  keys: Array<TKey> | TKey,
1104
1503
  config?: OperationConfig
1105
1504
  ): TransactionType<any> => {
1505
+ this.validateCollectionUsable(`delete`)
1506
+
1106
1507
  const ambientTransaction = getActiveTransaction()
1107
1508
 
1108
1509
  // If no ambient transaction exists, check for an onDelete handler early
@@ -1117,15 +1518,20 @@ export class CollectionImpl<
1117
1518
  }
1118
1519
 
1119
1520
  const keysArray = Array.isArray(keys) ? keys : [keys]
1120
- const mutations: Array<PendingMutation<T>> = []
1521
+ const mutations: Array<PendingMutation<T, `delete`>> = []
1121
1522
 
1122
1523
  for (const key of keysArray) {
1524
+ if (!this.has(key)) {
1525
+ throw new Error(
1526
+ `Collection.delete was called with key '${key}' but there is no item in the collection with this key`
1527
+ )
1528
+ }
1123
1529
  const globalKey = this.generateGlobalKey(key, this.get(key)!)
1124
- const mutation: PendingMutation<T> = {
1530
+ const mutation: PendingMutation<T, `delete`> = {
1125
1531
  mutationId: crypto.randomUUID(),
1126
- original: this.get(key) || {},
1532
+ original: this.get(key)!,
1127
1533
  modified: this.get(key)!,
1128
- changes: this.get(key) || {},
1534
+ changes: this.get(key)!,
1129
1535
  globalKey,
1130
1536
  key,
1131
1537
  metadata: config?.metadata as unknown,
@@ -1266,6 +1672,9 @@ export class CollectionImpl<
1266
1672
  callback: (changes: Array<ChangeMessage<T>>) => void,
1267
1673
  { includeInitialState = false }: { includeInitialState?: boolean } = {}
1268
1674
  ): () => void {
1675
+ // Start sync and track subscriber
1676
+ this.addSubscriber()
1677
+
1269
1678
  if (includeInitialState) {
1270
1679
  // First send the current state as changes
1271
1680
  callback(this.currentStateAsChanges())
@@ -1276,6 +1685,7 @@ export class CollectionImpl<
1276
1685
 
1277
1686
  return () => {
1278
1687
  this.changeListeners.delete(callback)
1688
+ this.removeSubscriber()
1279
1689
  }
1280
1690
  }
1281
1691
 
@@ -1287,6 +1697,9 @@ export class CollectionImpl<
1287
1697
  listener: ChangeListener<T, TKey>,
1288
1698
  { includeInitialState = false }: { includeInitialState?: boolean } = {}
1289
1699
  ): () => void {
1700
+ // Start sync and track subscriber
1701
+ this.addSubscriber()
1702
+
1290
1703
  if (!this.changeKeyListeners.has(key)) {
1291
1704
  this.changeKeyListeners.set(key, new Set())
1292
1705
  }
@@ -1312,6 +1725,40 @@ export class CollectionImpl<
1312
1725
  this.changeKeyListeners.delete(key)
1313
1726
  }
1314
1727
  }
1728
+ this.removeSubscriber()
1729
+ }
1730
+ }
1731
+
1732
+ /**
1733
+ * Capture visible state for keys that will be affected by pending sync operations
1734
+ * This must be called BEFORE onTransactionStateChange clears optimistic state
1735
+ */
1736
+ private capturePreSyncVisibleState(): void {
1737
+ if (this.pendingSyncedTransactions.length === 0) return
1738
+
1739
+ // Clear any previous capture
1740
+ this.preSyncVisibleState.clear()
1741
+
1742
+ // Get all keys that will be affected by sync operations
1743
+ const syncedKeys = new Set<TKey>()
1744
+ for (const transaction of this.pendingSyncedTransactions) {
1745
+ for (const operation of transaction.operations) {
1746
+ syncedKeys.add(operation.key as TKey)
1747
+ }
1748
+ }
1749
+
1750
+ // Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
1751
+ for (const key of syncedKeys) {
1752
+ this.recentlySyncedKeys.add(key)
1753
+ }
1754
+
1755
+ // Only capture current visible state for keys that will be affected by sync operations
1756
+ // This is much more efficient than capturing the entire collection state
1757
+ for (const key of syncedKeys) {
1758
+ const currentValue = this.get(key)
1759
+ if (currentValue !== undefined) {
1760
+ this.preSyncVisibleState.set(key, currentValue)
1761
+ }
1315
1762
  }
1316
1763
  }
1317
1764
 
@@ -1320,6 +1767,9 @@ export class CollectionImpl<
1320
1767
  * This method should be called by the Transaction class when state changes
1321
1768
  */
1322
1769
  public onTransactionStateChange(): void {
1770
+ // CRITICAL: Capture visible state BEFORE clearing optimistic state
1771
+ this.capturePreSyncVisibleState()
1772
+
1323
1773
  this.recomputeOptimisticState()
1324
1774
  }
1325
1775
 
@@ -1334,7 +1784,7 @@ export class CollectionImpl<
1334
1784
  public asStoreMap(): Store<Map<TKey, T>> {
1335
1785
  if (!this._storeMap) {
1336
1786
  this._storeMap = new Store(new Map(this.entries()))
1337
- this.subscribeChanges(() => {
1787
+ this.changeListeners.add(() => {
1338
1788
  this._storeMap!.setState(() => new Map(this.entries()))
1339
1789
  })
1340
1790
  }
@@ -1352,7 +1802,7 @@ export class CollectionImpl<
1352
1802
  public asStoreArray(): Store<Array<T>> {
1353
1803
  if (!this._storeArray) {
1354
1804
  this._storeArray = new Store(this.toArray)
1355
- this.subscribeChanges(() => {
1805
+ this.changeListeners.add(() => {
1356
1806
  this._storeArray!.setState(() => this.toArray)
1357
1807
  })
1358
1808
  }