@tanstack/db 0.3.1 → 0.4.0

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 (162) hide show
  1. package/dist/cjs/{change-events.cjs → collection/change-events.cjs} +13 -42
  2. package/dist/cjs/collection/change-events.cjs.map +1 -0
  3. package/dist/{esm/change-events.d.ts → cjs/collection/change-events.d.cts} +6 -6
  4. package/dist/cjs/collection/changes.cjs +108 -0
  5. package/dist/cjs/collection/changes.cjs.map +1 -0
  6. package/dist/cjs/collection/changes.d.cts +53 -0
  7. package/dist/cjs/collection/events.cjs +90 -0
  8. package/dist/cjs/collection/events.cjs.map +1 -0
  9. package/dist/cjs/collection/events.d.cts +53 -0
  10. package/dist/cjs/collection/index.cjs +417 -0
  11. package/dist/cjs/collection/index.cjs.map +1 -0
  12. package/dist/{esm/collection.d.ts → cjs/collection/index.d.cts} +56 -172
  13. package/dist/cjs/collection/indexes.cjs +124 -0
  14. package/dist/cjs/collection/indexes.cjs.map +1 -0
  15. package/dist/cjs/collection/indexes.d.cts +47 -0
  16. package/dist/cjs/collection/lifecycle.cjs +150 -0
  17. package/dist/cjs/collection/lifecycle.cjs.map +1 -0
  18. package/dist/cjs/collection/lifecycle.d.cts +70 -0
  19. package/dist/cjs/collection/mutations.cjs +315 -0
  20. package/dist/cjs/collection/mutations.cjs.map +1 -0
  21. package/dist/cjs/collection/mutations.d.cts +33 -0
  22. package/dist/cjs/collection/state.cjs +597 -0
  23. package/dist/cjs/collection/state.cjs.map +1 -0
  24. package/dist/cjs/collection/state.d.cts +122 -0
  25. package/dist/cjs/collection/subscription.cjs +160 -0
  26. package/dist/cjs/collection/subscription.cjs.map +1 -0
  27. package/dist/cjs/collection/subscription.d.cts +57 -0
  28. package/dist/cjs/collection/sync.cjs +154 -0
  29. package/dist/cjs/collection/sync.cjs.map +1 -0
  30. package/dist/cjs/collection/sync.d.cts +34 -0
  31. package/dist/cjs/index.cjs +8 -8
  32. package/dist/cjs/index.d.cts +2 -2
  33. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  34. package/dist/cjs/indexes/auto-index.d.cts +1 -1
  35. package/dist/cjs/indexes/base-index.cjs.map +1 -1
  36. package/dist/cjs/indexes/base-index.d.cts +2 -2
  37. package/dist/cjs/indexes/btree-index.cjs +2 -2
  38. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  39. package/dist/cjs/indexes/btree-index.d.cts +1 -1
  40. package/dist/cjs/query/builder/index.cjs +2 -2
  41. package/dist/cjs/query/builder/index.cjs.map +1 -1
  42. package/dist/cjs/query/builder/types.d.cts +1 -1
  43. package/dist/cjs/query/compiler/index.cjs +5 -2
  44. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  45. package/dist/cjs/query/compiler/index.d.cts +3 -2
  46. package/dist/cjs/query/compiler/joins.cjs +22 -24
  47. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  48. package/dist/cjs/query/compiler/joins.d.cts +3 -2
  49. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  50. package/dist/cjs/query/compiler/order-by.d.cts +1 -1
  51. package/dist/cjs/query/ir.cjs.map +1 -1
  52. package/dist/cjs/query/ir.d.cts +1 -1
  53. package/dist/cjs/query/live/collection-config-builder.cjs +29 -12
  54. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  55. package/dist/cjs/query/live/collection-config-builder.d.cts +3 -0
  56. package/dist/cjs/query/live/collection-subscriber.cjs +43 -104
  57. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  58. package/dist/cjs/query/live/collection-subscriber.d.cts +4 -7
  59. package/dist/cjs/query/live-query-collection.cjs +2 -2
  60. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  61. package/dist/cjs/query/live-query-collection.d.cts +1 -1
  62. package/dist/cjs/transactions.cjs +3 -3
  63. package/dist/cjs/transactions.cjs.map +1 -1
  64. package/dist/cjs/types.d.cts +12 -10
  65. package/dist/{cjs/change-events.d.cts → esm/collection/change-events.d.ts} +6 -6
  66. package/dist/esm/{change-events.js → collection/change-events.js} +13 -42
  67. package/dist/esm/collection/change-events.js.map +1 -0
  68. package/dist/esm/collection/changes.d.ts +53 -0
  69. package/dist/esm/collection/changes.js +108 -0
  70. package/dist/esm/collection/changes.js.map +1 -0
  71. package/dist/esm/collection/events.d.ts +53 -0
  72. package/dist/esm/collection/events.js +90 -0
  73. package/dist/esm/collection/events.js.map +1 -0
  74. package/dist/{cjs/collection.d.cts → esm/collection/index.d.ts} +56 -172
  75. package/dist/esm/collection/index.js +417 -0
  76. package/dist/esm/collection/index.js.map +1 -0
  77. package/dist/esm/collection/indexes.d.ts +47 -0
  78. package/dist/esm/collection/indexes.js +124 -0
  79. package/dist/esm/collection/indexes.js.map +1 -0
  80. package/dist/esm/collection/lifecycle.d.ts +70 -0
  81. package/dist/esm/collection/lifecycle.js +150 -0
  82. package/dist/esm/collection/lifecycle.js.map +1 -0
  83. package/dist/esm/collection/mutations.d.ts +33 -0
  84. package/dist/esm/collection/mutations.js +315 -0
  85. package/dist/esm/collection/mutations.js.map +1 -0
  86. package/dist/esm/collection/state.d.ts +122 -0
  87. package/dist/esm/collection/state.js +597 -0
  88. package/dist/esm/collection/state.js.map +1 -0
  89. package/dist/esm/collection/subscription.d.ts +57 -0
  90. package/dist/esm/collection/subscription.js +160 -0
  91. package/dist/esm/collection/subscription.js.map +1 -0
  92. package/dist/esm/collection/sync.d.ts +34 -0
  93. package/dist/esm/collection/sync.js +154 -0
  94. package/dist/esm/collection/sync.js.map +1 -0
  95. package/dist/esm/index.d.ts +2 -2
  96. package/dist/esm/index.js +1 -1
  97. package/dist/esm/indexes/auto-index.d.ts +1 -1
  98. package/dist/esm/indexes/auto-index.js.map +1 -1
  99. package/dist/esm/indexes/base-index.d.ts +2 -2
  100. package/dist/esm/indexes/base-index.js.map +1 -1
  101. package/dist/esm/indexes/btree-index.d.ts +1 -1
  102. package/dist/esm/indexes/btree-index.js +2 -2
  103. package/dist/esm/indexes/btree-index.js.map +1 -1
  104. package/dist/esm/proxy.js +1 -1
  105. package/dist/esm/query/builder/index.js +1 -1
  106. package/dist/esm/query/builder/index.js.map +1 -1
  107. package/dist/esm/query/builder/types.d.ts +1 -1
  108. package/dist/esm/query/compiler/index.d.ts +3 -2
  109. package/dist/esm/query/compiler/index.js +5 -2
  110. package/dist/esm/query/compiler/index.js.map +1 -1
  111. package/dist/esm/query/compiler/joins.d.ts +3 -2
  112. package/dist/esm/query/compiler/joins.js +22 -24
  113. package/dist/esm/query/compiler/joins.js.map +1 -1
  114. package/dist/esm/query/compiler/order-by.d.ts +1 -1
  115. package/dist/esm/query/compiler/order-by.js.map +1 -1
  116. package/dist/esm/query/ir.d.ts +1 -1
  117. package/dist/esm/query/ir.js.map +1 -1
  118. package/dist/esm/query/live/collection-config-builder.d.ts +3 -0
  119. package/dist/esm/query/live/collection-config-builder.js +29 -12
  120. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  121. package/dist/esm/query/live/collection-subscriber.d.ts +4 -7
  122. package/dist/esm/query/live/collection-subscriber.js +43 -104
  123. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  124. package/dist/esm/query/live-query-collection.d.ts +1 -1
  125. package/dist/esm/query/live-query-collection.js +1 -1
  126. package/dist/esm/query/live-query-collection.js.map +1 -1
  127. package/dist/esm/transactions.js +3 -3
  128. package/dist/esm/transactions.js.map +1 -1
  129. package/dist/esm/types.d.ts +12 -10
  130. package/package.json +2 -2
  131. package/src/{change-events.ts → collection/change-events.ts} +25 -39
  132. package/src/collection/changes.ts +163 -0
  133. package/src/collection/events.ts +171 -0
  134. package/src/collection/index.ts +808 -0
  135. package/src/collection/indexes.ts +172 -0
  136. package/src/collection/lifecycle.ts +221 -0
  137. package/src/collection/mutations.ts +535 -0
  138. package/src/collection/state.ts +866 -0
  139. package/src/collection/subscription.ts +239 -0
  140. package/src/collection/sync.ts +235 -0
  141. package/src/index.ts +2 -2
  142. package/src/indexes/auto-index.ts +1 -1
  143. package/src/indexes/base-index.ts +3 -3
  144. package/src/indexes/btree-index.ts +2 -2
  145. package/src/query/builder/index.ts +1 -1
  146. package/src/query/builder/types.ts +1 -1
  147. package/src/query/compiler/index.ts +7 -1
  148. package/src/query/compiler/joins.ts +28 -41
  149. package/src/query/compiler/order-by.ts +1 -1
  150. package/src/query/ir.ts +1 -1
  151. package/src/query/live/collection-config-builder.ts +48 -22
  152. package/src/query/live/collection-subscriber.ts +63 -168
  153. package/src/query/live-query-collection.ts +2 -2
  154. package/src/transactions.ts +3 -3
  155. package/src/types.ts +14 -15
  156. package/dist/cjs/change-events.cjs.map +0 -1
  157. package/dist/cjs/collection.cjs +0 -1580
  158. package/dist/cjs/collection.cjs.map +0 -1
  159. package/dist/esm/change-events.js.map +0 -1
  160. package/dist/esm/collection.js +0 -1580
  161. package/dist/esm/collection.js.map +0 -1
  162. package/src/collection.ts +0 -2488
package/src/collection.ts DELETED
@@ -1,2488 +0,0 @@
1
- import { withArrayChangeTracking, withChangeTracking } from "./proxy"
2
- import { deepEquals } from "./utils"
3
- import { SortedMap } from "./SortedMap"
4
- import {
5
- createSingleRowRefProxy,
6
- toExpression,
7
- } from "./query/builder/ref-proxy"
8
- import { BTreeIndex } from "./indexes/btree-index.js"
9
- import { IndexProxy, LazyIndexWrapper } from "./indexes/lazy-index.js"
10
- import { ensureIndexForExpression } from "./indexes/auto-index.js"
11
- import { createTransaction, getActiveTransaction } from "./transactions"
12
- import {
13
- CollectionInErrorStateError,
14
- CollectionIsInErrorStateError,
15
- CollectionRequiresConfigError,
16
- CollectionRequiresSyncConfigError,
17
- DeleteKeyNotFoundError,
18
- DuplicateKeyError,
19
- DuplicateKeySyncError,
20
- InvalidCollectionStatusTransitionError,
21
- InvalidSchemaError,
22
- KeyUpdateNotAllowedError,
23
- MissingDeleteHandlerError,
24
- MissingInsertHandlerError,
25
- MissingUpdateArgumentError,
26
- MissingUpdateHandlerError,
27
- NegativeActiveSubscribersError,
28
- NoKeysPassedToDeleteError,
29
- NoKeysPassedToUpdateError,
30
- NoPendingSyncTransactionCommitError,
31
- NoPendingSyncTransactionWriteError,
32
- SchemaMustBeSynchronousError,
33
- SchemaValidationError,
34
- SyncCleanupError,
35
- SyncTransactionAlreadyCommittedError,
36
- SyncTransactionAlreadyCommittedWriteError,
37
- UndefinedKeyError,
38
- UpdateKeyNotFoundError,
39
- } from "./errors"
40
- import { createFilteredCallback, currentStateAsChanges } from "./change-events"
41
- import type { Transaction } from "./transactions"
42
- import type { StandardSchemaV1 } from "@standard-schema/spec"
43
- import type { SingleRowRefProxy } from "./query/builder/ref-proxy"
44
- import type {
45
- ChangeListener,
46
- ChangeMessage,
47
- CollectionConfig,
48
- CollectionStatus,
49
- CurrentStateAsChangesOptions,
50
- Fn,
51
- InferSchemaInput,
52
- InferSchemaOutput,
53
- InsertConfig,
54
- OperationConfig,
55
- OptimisticChangeMessage,
56
- PendingMutation,
57
- StandardSchema,
58
- SubscribeChangesOptions,
59
- Transaction as TransactionType,
60
- TransactionWithMutations,
61
- UtilsRecord,
62
- WritableDeep,
63
- } from "./types"
64
- import type { IndexOptions } from "./indexes/index-options.js"
65
- import type { BaseIndex, IndexResolver } from "./indexes/base-index.js"
66
-
67
- interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
68
- committed: boolean
69
- operations: Array<OptimisticChangeMessage<T>>
70
- truncate?: boolean
71
- deletedKeys: Set<string | number>
72
- }
73
-
74
- /**
75
- * Enhanced Collection interface that includes both data type T and utilities TUtils
76
- * @template T - The type of items in the collection
77
- * @template TKey - The type of the key for the collection
78
- * @template TUtils - The utilities record type
79
- * @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults)
80
- */
81
- export interface Collection<
82
- T extends object = Record<string, unknown>,
83
- TKey extends string | number = string | number,
84
- TUtils extends UtilsRecord = {},
85
- TSchema extends StandardSchemaV1 = StandardSchemaV1,
86
- TInsertInput extends object = T,
87
- > extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> {
88
- readonly utils: TUtils
89
- }
90
-
91
- /**
92
- * Creates a new Collection instance with the given configuration
93
- *
94
- * @template T - The schema type if a schema is provided, otherwise the type of items in the collection
95
- * @template TKey - The type of the key for the collection
96
- * @template TUtils - The utilities record type
97
- * @param options - Collection options with optional utilities
98
- * @returns A new Collection with utilities exposed both at top level and under .utils
99
- *
100
- * @example
101
- * // Pattern 1: With operation handlers (direct collection calls)
102
- * const todos = createCollection({
103
- * id: "todos",
104
- * getKey: (todo) => todo.id,
105
- * schema,
106
- * onInsert: async ({ transaction, collection }) => {
107
- * // Send to API
108
- * await api.createTodo(transaction.mutations[0].modified)
109
- * },
110
- * onUpdate: async ({ transaction, collection }) => {
111
- * await api.updateTodo(transaction.mutations[0].modified)
112
- * },
113
- * onDelete: async ({ transaction, collection }) => {
114
- * await api.deleteTodo(transaction.mutations[0].key)
115
- * },
116
- * sync: { sync: () => {} }
117
- * })
118
- *
119
- * // Direct usage (handlers manage transactions)
120
- * const tx = todos.insert({ id: "1", text: "Buy milk", completed: false })
121
- * await tx.isPersisted.promise
122
- *
123
- * @example
124
- * // Pattern 2: Manual transaction management
125
- * const todos = createCollection({
126
- * getKey: (todo) => todo.id,
127
- * schema: todoSchema,
128
- * sync: { sync: () => {} }
129
- * })
130
- *
131
- * // Explicit transaction usage
132
- * const tx = createTransaction({
133
- * mutationFn: async ({ transaction }) => {
134
- * // Handle all mutations in transaction
135
- * await api.saveChanges(transaction.mutations)
136
- * }
137
- * })
138
- *
139
- * tx.mutate(() => {
140
- * todos.insert({ id: "1", text: "Buy milk" })
141
- * todos.update("2", draft => { draft.completed = true })
142
- * })
143
- *
144
- * await tx.isPersisted.promise
145
- *
146
- * @example
147
- * // Using schema for type inference (preferred as it also gives you client side validation)
148
- * const todoSchema = z.object({
149
- * id: z.string(),
150
- * title: z.string(),
151
- * completed: z.boolean()
152
- * })
153
- *
154
- * const todos = createCollection({
155
- * schema: todoSchema,
156
- * getKey: (todo) => todo.id,
157
- * sync: { sync: () => {} }
158
- * })
159
- *
160
- */
161
-
162
- // Overload for when schema is provided
163
- export function createCollection<
164
- T extends StandardSchemaV1,
165
- TKey extends string | number = string | number,
166
- TUtils extends UtilsRecord = {},
167
- >(
168
- options: CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
169
- schema: T
170
- utils?: TUtils
171
- }
172
- ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>>
173
-
174
- // Overload for when no schema is provided
175
- // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config
176
- export function createCollection<
177
- T extends object,
178
- TKey extends string | number = string | number,
179
- TUtils extends UtilsRecord = {},
180
- >(
181
- options: CollectionConfig<T, TKey, never> & {
182
- schema?: never // prohibit schema if an explicit type is provided
183
- utils?: TUtils
184
- }
185
- ): Collection<T, TKey, TUtils, never, T>
186
-
187
- // Implementation
188
- export function createCollection(
189
- options: CollectionConfig<any, string | number, any> & {
190
- schema?: StandardSchemaV1
191
- utils?: UtilsRecord
192
- }
193
- ): Collection<any, string | number, UtilsRecord, any, any> {
194
- const collection = new CollectionImpl<any, string | number, any, any, any>(
195
- options
196
- )
197
-
198
- // Copy utils to both top level and .utils namespace
199
- if (options.utils) {
200
- collection.utils = { ...options.utils }
201
- } else {
202
- collection.utils = {}
203
- }
204
-
205
- return collection
206
- }
207
-
208
- export class CollectionImpl<
209
- TOutput extends object = Record<string, unknown>,
210
- TKey extends string | number = string | number,
211
- TUtils extends UtilsRecord = {},
212
- TSchema extends StandardSchemaV1 = StandardSchemaV1,
213
- TInput extends object = TOutput,
214
- > {
215
- public config: CollectionConfig<TOutput, TKey, TSchema>
216
-
217
- // Core state - make public for testing
218
- public transactions: SortedMap<string, Transaction<any>>
219
- public pendingSyncedTransactions: Array<PendingSyncedTransaction<TOutput>> =
220
- []
221
- public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
222
- public syncedMetadata = new Map<TKey, unknown>()
223
-
224
- // Optimistic state tracking - make public for testing
225
- public optimisticUpserts = new Map<TKey, TOutput>()
226
- public optimisticDeletes = new Set<TKey>()
227
-
228
- // Cached size for performance
229
- private _size = 0
230
-
231
- // Index storage
232
- private lazyIndexes = new Map<number, LazyIndexWrapper<TKey>>()
233
- private resolvedIndexes = new Map<number, BaseIndex<TKey>>()
234
- private isIndexesResolved = false
235
- private indexCounter = 0
236
-
237
- // Event system
238
- private changeListeners = new Set<ChangeListener<TOutput, TKey>>()
239
- private changeKeyListeners = new Map<
240
- TKey,
241
- Set<ChangeListener<TOutput, TKey>>
242
- >()
243
-
244
- // Utilities namespace
245
- // This is populated by createCollection
246
- public utils: Record<string, Fn> = {}
247
-
248
- // State used for computing the change events
249
- private syncedKeys = new Set<TKey>()
250
- private preSyncVisibleState = new Map<TKey, TOutput>()
251
- private recentlySyncedKeys = new Set<TKey>()
252
- private hasReceivedFirstCommit = false
253
- private isCommittingSyncTransactions = false
254
-
255
- // Array to store one-time ready listeners
256
- private onFirstReadyCallbacks: Array<() => void> = []
257
- private hasBeenReady = false
258
-
259
- // Event batching for preventing duplicate emissions during transaction flows
260
- private batchedEvents: Array<ChangeMessage<TOutput, TKey>> = []
261
- private shouldBatchEvents = false
262
-
263
- // Lifecycle management
264
- private _status: CollectionStatus = `idle`
265
- private activeSubscribersCount = 0
266
- private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
267
- private preloadPromise: Promise<void> | null = null
268
- private syncCleanupFn: (() => void) | null = null
269
-
270
- /**
271
- * Register a callback to be executed when the collection first becomes ready
272
- * Useful for preloading collections
273
- * @param callback Function to call when the collection first becomes ready
274
- * @example
275
- * collection.onFirstReady(() => {
276
- * console.log('Collection is ready for the first time')
277
- * // Safe to access collection.state now
278
- * })
279
- */
280
- public onFirstReady(callback: () => void): void {
281
- // If already ready, call immediately
282
- if (this.hasBeenReady) {
283
- callback()
284
- return
285
- }
286
-
287
- this.onFirstReadyCallbacks.push(callback)
288
- }
289
-
290
- /**
291
- * Check if the collection is ready for use
292
- * Returns true if the collection has been marked as ready by its sync implementation
293
- * @returns true if the collection is ready, false otherwise
294
- * @example
295
- * if (collection.isReady()) {
296
- * console.log('Collection is ready, data is available')
297
- * // Safe to access collection.state
298
- * } else {
299
- * console.log('Collection is still loading')
300
- * }
301
- */
302
- public isReady(): boolean {
303
- return this._status === `ready`
304
- }
305
-
306
- /**
307
- * Mark the collection as ready for use
308
- * This is called by sync implementations to explicitly signal that the collection is ready,
309
- * providing a more intuitive alternative to using commits for readiness signaling
310
- * @private - Should only be called by sync implementations
311
- */
312
- private markReady(): void {
313
- // Can transition to ready from loading or initialCommit states
314
- if (this._status === `loading` || this._status === `initialCommit`) {
315
- this.setStatus(`ready`)
316
-
317
- // Call any registered first ready callbacks (only on first time becoming ready)
318
- if (!this.hasBeenReady) {
319
- this.hasBeenReady = true
320
-
321
- // Also mark as having received first commit for backwards compatibility
322
- if (!this.hasReceivedFirstCommit) {
323
- this.hasReceivedFirstCommit = true
324
- }
325
-
326
- const callbacks = [...this.onFirstReadyCallbacks]
327
- this.onFirstReadyCallbacks = []
328
- callbacks.forEach((callback) => callback())
329
- }
330
- }
331
-
332
- // Always notify dependents when markReady is called, after status is set
333
- // This ensures live queries get notified when their dependencies become ready
334
- if (this.changeListeners.size > 0) {
335
- this.emitEmptyReadyEvent()
336
- }
337
- }
338
-
339
- public id = ``
340
-
341
- /**
342
- * Gets the current status of the collection
343
- */
344
- public get status(): CollectionStatus {
345
- return this._status
346
- }
347
-
348
- /**
349
- * Validates that the collection is in a usable state for data operations
350
- * @private
351
- */
352
- private validateCollectionUsable(operation: string): void {
353
- switch (this._status) {
354
- case `error`:
355
- throw new CollectionInErrorStateError(operation, this.id)
356
- case `cleaned-up`:
357
- // Automatically restart the collection when operations are called on cleaned-up collections
358
- this.startSync()
359
- break
360
- }
361
- }
362
-
363
- /**
364
- * Validates state transitions to prevent invalid status changes
365
- * @private
366
- */
367
- private validateStatusTransition(
368
- from: CollectionStatus,
369
- to: CollectionStatus
370
- ): void {
371
- if (from === to) {
372
- // Allow same state transitions
373
- return
374
- }
375
- const validTransitions: Record<
376
- CollectionStatus,
377
- Array<CollectionStatus>
378
- > = {
379
- idle: [`loading`, `error`, `cleaned-up`],
380
- loading: [`initialCommit`, `ready`, `error`, `cleaned-up`],
381
- initialCommit: [`ready`, `error`, `cleaned-up`],
382
- ready: [`cleaned-up`, `error`],
383
- error: [`cleaned-up`, `idle`],
384
- "cleaned-up": [`loading`, `error`],
385
- }
386
-
387
- if (!validTransitions[from].includes(to)) {
388
- throw new InvalidCollectionStatusTransitionError(from, to, this.id)
389
- }
390
- }
391
-
392
- /**
393
- * Safely update the collection status with validation
394
- * @private
395
- */
396
- private setStatus(newStatus: CollectionStatus): void {
397
- this.validateStatusTransition(this._status, newStatus)
398
- this._status = newStatus
399
-
400
- // Resolve indexes when collection becomes ready
401
- if (newStatus === `ready` && !this.isIndexesResolved) {
402
- // Resolve indexes asynchronously without blocking
403
- this.resolveAllIndexes().catch((error) => {
404
- console.warn(`Failed to resolve indexes:`, error)
405
- })
406
- }
407
- }
408
-
409
- /**
410
- * Creates a new Collection instance
411
- *
412
- * @param config - Configuration object for the collection
413
- * @throws Error if sync config is missing
414
- */
415
- constructor(config: CollectionConfig<TOutput, TKey, TSchema>) {
416
- // eslint-disable-next-line
417
- if (!config) {
418
- throw new CollectionRequiresConfigError()
419
- }
420
- if (config.id) {
421
- this.id = config.id
422
- } else {
423
- this.id = crypto.randomUUID()
424
- }
425
-
426
- // eslint-disable-next-line
427
- if (!config.sync) {
428
- throw new CollectionRequiresSyncConfigError()
429
- }
430
-
431
- this.transactions = new SortedMap<string, Transaction<any>>((a, b) =>
432
- a.compareCreatedAt(b)
433
- )
434
-
435
- // Set default values for optional config properties
436
- this.config = {
437
- ...config,
438
- autoIndex: config.autoIndex ?? `eager`,
439
- }
440
-
441
- // Set up data storage with optional comparison function
442
- if (this.config.compare) {
443
- this.syncedData = new SortedMap<TKey, TOutput>(this.config.compare)
444
- } else {
445
- this.syncedData = new Map<TKey, TOutput>()
446
- }
447
-
448
- // Only start sync immediately if explicitly enabled
449
- if (config.startSync === true) {
450
- this.startSync()
451
- }
452
- }
453
-
454
- /**
455
- * Start sync immediately - internal method for compiled queries
456
- * This bypasses lazy loading for special cases like live query results
457
- */
458
- public startSyncImmediate(): void {
459
- this.startSync()
460
- }
461
-
462
- /**
463
- * Start the sync process for this collection
464
- * This is called when the collection is first accessed or preloaded
465
- */
466
- private startSync(): void {
467
- if (this._status !== `idle` && this._status !== `cleaned-up`) {
468
- return // Already started or in progress
469
- }
470
-
471
- this.setStatus(`loading`)
472
-
473
- try {
474
- const cleanupFn = this.config.sync.sync({
475
- collection: this,
476
- begin: () => {
477
- this.pendingSyncedTransactions.push({
478
- committed: false,
479
- operations: [],
480
- deletedKeys: new Set(),
481
- })
482
- },
483
- write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
484
- const pendingTransaction =
485
- this.pendingSyncedTransactions[
486
- this.pendingSyncedTransactions.length - 1
487
- ]
488
- if (!pendingTransaction) {
489
- throw new NoPendingSyncTransactionWriteError()
490
- }
491
- if (pendingTransaction.committed) {
492
- throw new SyncTransactionAlreadyCommittedWriteError()
493
- }
494
- const key = this.getKeyFromItem(messageWithoutKey.value)
495
-
496
- // Check if an item with this key already exists when inserting
497
- if (messageWithoutKey.type === `insert`) {
498
- const insertingIntoExistingSynced = this.syncedData.has(key)
499
- const hasPendingDeleteForKey =
500
- pendingTransaction.deletedKeys.has(key)
501
- const isTruncateTransaction = pendingTransaction.truncate === true
502
- // Allow insert after truncate in the same transaction even if it existed in syncedData
503
- if (
504
- insertingIntoExistingSynced &&
505
- !hasPendingDeleteForKey &&
506
- !isTruncateTransaction
507
- ) {
508
- throw new DuplicateKeySyncError(key, this.id)
509
- }
510
- }
511
-
512
- const message: ChangeMessage<TOutput> = {
513
- ...messageWithoutKey,
514
- key,
515
- }
516
- pendingTransaction.operations.push(message)
517
-
518
- if (messageWithoutKey.type === `delete`) {
519
- pendingTransaction.deletedKeys.add(key)
520
- }
521
- },
522
- commit: () => {
523
- const pendingTransaction =
524
- this.pendingSyncedTransactions[
525
- this.pendingSyncedTransactions.length - 1
526
- ]
527
- if (!pendingTransaction) {
528
- throw new NoPendingSyncTransactionCommitError()
529
- }
530
- if (pendingTransaction.committed) {
531
- throw new SyncTransactionAlreadyCommittedError()
532
- }
533
-
534
- pendingTransaction.committed = true
535
-
536
- // Update status to initialCommit when transitioning from loading
537
- // This indicates we're in the process of committing the first transaction
538
- if (this._status === `loading`) {
539
- this.setStatus(`initialCommit`)
540
- }
541
-
542
- this.commitPendingTransactions()
543
- },
544
- markReady: () => {
545
- this.markReady()
546
- },
547
- truncate: () => {
548
- const pendingTransaction =
549
- this.pendingSyncedTransactions[
550
- this.pendingSyncedTransactions.length - 1
551
- ]
552
- if (!pendingTransaction) {
553
- throw new NoPendingSyncTransactionWriteError()
554
- }
555
- if (pendingTransaction.committed) {
556
- throw new SyncTransactionAlreadyCommittedWriteError()
557
- }
558
-
559
- // Clear all operations from the current transaction
560
- pendingTransaction.operations = []
561
- pendingTransaction.deletedKeys.clear()
562
-
563
- // Mark the transaction as a truncate operation. During commit, this triggers:
564
- // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
565
- // - Clearing of syncedData/syncedMetadata
566
- // - Subsequent synced ops applied on the fresh base
567
- // - Finally, optimistic mutations re-applied on top (single batch)
568
- pendingTransaction.truncate = true
569
- },
570
- })
571
-
572
- // Store cleanup function if provided
573
- this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
574
- } catch (error) {
575
- this.setStatus(`error`)
576
- throw error
577
- }
578
- }
579
-
580
- /**
581
- * Preload the collection data by starting sync if not already started
582
- * Multiple concurrent calls will share the same promise
583
- */
584
- public preload(): Promise<void> {
585
- if (this.preloadPromise) {
586
- return this.preloadPromise
587
- }
588
-
589
- this.preloadPromise = new Promise<void>((resolve, reject) => {
590
- if (this._status === `ready`) {
591
- resolve()
592
- return
593
- }
594
-
595
- if (this._status === `error`) {
596
- reject(new CollectionIsInErrorStateError())
597
- return
598
- }
599
-
600
- // Register callback BEFORE starting sync to avoid race condition
601
- this.onFirstReady(() => {
602
- resolve()
603
- })
604
-
605
- // Start sync if collection hasn't started yet or was cleaned up
606
- if (this._status === `idle` || this._status === `cleaned-up`) {
607
- try {
608
- this.startSync()
609
- } catch (error) {
610
- reject(error)
611
- return
612
- }
613
- }
614
- })
615
-
616
- return this.preloadPromise
617
- }
618
-
619
- /**
620
- * Clean up the collection by stopping sync and clearing data
621
- * This can be called manually or automatically by garbage collection
622
- */
623
- public async cleanup(): Promise<void> {
624
- // Clear GC timeout
625
- if (this.gcTimeoutId) {
626
- clearTimeout(this.gcTimeoutId)
627
- this.gcTimeoutId = null
628
- }
629
-
630
- // Stop sync - wrap in try/catch since it's user-provided code
631
- try {
632
- if (this.syncCleanupFn) {
633
- this.syncCleanupFn()
634
- this.syncCleanupFn = null
635
- }
636
- } catch (error) {
637
- // Re-throw in a microtask to surface the error after cleanup completes
638
- queueMicrotask(() => {
639
- if (error instanceof Error) {
640
- // Preserve the original error and stack trace
641
- const wrappedError = new SyncCleanupError(this.id, error)
642
- wrappedError.cause = error
643
- wrappedError.stack = error.stack
644
- throw wrappedError
645
- } else {
646
- throw new SyncCleanupError(this.id, error as Error | string)
647
- }
648
- })
649
- }
650
-
651
- // Clear data
652
- this.syncedData.clear()
653
- this.syncedMetadata.clear()
654
- this.optimisticUpserts.clear()
655
- this.optimisticDeletes.clear()
656
- this._size = 0
657
- this.pendingSyncedTransactions = []
658
- this.syncedKeys.clear()
659
- this.hasReceivedFirstCommit = false
660
- this.hasBeenReady = false
661
- this.onFirstReadyCallbacks = []
662
- this.preloadPromise = null
663
- this.batchedEvents = []
664
- this.shouldBatchEvents = false
665
-
666
- // Update status
667
- this.setStatus(`cleaned-up`)
668
-
669
- return Promise.resolve()
670
- }
671
-
672
- /**
673
- * Start the garbage collection timer
674
- * Called when the collection becomes inactive (no subscribers)
675
- */
676
- private startGCTimer(): void {
677
- if (this.gcTimeoutId) {
678
- clearTimeout(this.gcTimeoutId)
679
- }
680
-
681
- const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
682
-
683
- // If gcTime is 0, GC is disabled
684
- if (gcTime === 0) {
685
- return
686
- }
687
-
688
- this.gcTimeoutId = setTimeout(() => {
689
- if (this.activeSubscribersCount === 0) {
690
- this.cleanup()
691
- }
692
- }, gcTime)
693
- }
694
-
695
- /**
696
- * Cancel the garbage collection timer
697
- * Called when the collection becomes active again
698
- */
699
- private cancelGCTimer(): void {
700
- if (this.gcTimeoutId) {
701
- clearTimeout(this.gcTimeoutId)
702
- this.gcTimeoutId = null
703
- }
704
- }
705
-
706
- /**
707
- * Increment the active subscribers count and start sync if needed
708
- */
709
- private addSubscriber(): void {
710
- this.activeSubscribersCount++
711
- this.cancelGCTimer()
712
-
713
- // Start sync if collection was cleaned up
714
- if (this._status === `cleaned-up` || this._status === `idle`) {
715
- this.startSync()
716
- }
717
- }
718
-
719
- /**
720
- * Decrement the active subscribers count and start GC timer if needed
721
- */
722
- private removeSubscriber(): void {
723
- this.activeSubscribersCount--
724
-
725
- if (this.activeSubscribersCount === 0) {
726
- this.startGCTimer()
727
- } else if (this.activeSubscribersCount < 0) {
728
- throw new NegativeActiveSubscribersError()
729
- }
730
- }
731
-
732
- /**
733
- * Recompute optimistic state from active transactions
734
- */
735
- private recomputeOptimisticState(
736
- triggeredByUserAction: boolean = false
737
- ): void {
738
- // Skip redundant recalculations when we're in the middle of committing sync transactions
739
- if (this.isCommittingSyncTransactions) {
740
- return
741
- }
742
-
743
- const previousState = new Map(this.optimisticUpserts)
744
- const previousDeletes = new Set(this.optimisticDeletes)
745
-
746
- // Clear current optimistic state
747
- this.optimisticUpserts.clear()
748
- this.optimisticDeletes.clear()
749
-
750
- const activeTransactions: Array<Transaction<any>> = []
751
-
752
- for (const transaction of this.transactions.values()) {
753
- if (![`completed`, `failed`].includes(transaction.state)) {
754
- activeTransactions.push(transaction)
755
- }
756
- }
757
-
758
- // Apply active transactions only (completed transactions are handled by sync operations)
759
- for (const transaction of activeTransactions) {
760
- for (const mutation of transaction.mutations) {
761
- if (mutation.collection === this && mutation.optimistic) {
762
- switch (mutation.type) {
763
- case `insert`:
764
- case `update`:
765
- this.optimisticUpserts.set(
766
- mutation.key,
767
- mutation.modified as TOutput
768
- )
769
- this.optimisticDeletes.delete(mutation.key)
770
- break
771
- case `delete`:
772
- this.optimisticUpserts.delete(mutation.key)
773
- this.optimisticDeletes.add(mutation.key)
774
- break
775
- }
776
- }
777
- }
778
- }
779
-
780
- // Update cached size
781
- this._size = this.calculateSize()
782
-
783
- // Collect events for changes
784
- const events: Array<ChangeMessage<TOutput, TKey>> = []
785
- this.collectOptimisticChanges(previousState, previousDeletes, events)
786
-
787
- // Filter out events for recently synced keys to prevent duplicates
788
- // BUT: Only filter out events that are actually from sync operations
789
- // New user transactions should NOT be filtered even if the key was recently synced
790
- const filteredEventsBySyncStatus = events.filter((event) => {
791
- if (!this.recentlySyncedKeys.has(event.key)) {
792
- return true // Key not recently synced, allow event through
793
- }
794
-
795
- // Key was recently synced - allow if this is a user-triggered action
796
- if (triggeredByUserAction) {
797
- return true
798
- }
799
-
800
- // Otherwise filter out duplicate sync events
801
- return false
802
- })
803
-
804
- // Filter out redundant delete events if there are pending sync transactions
805
- // that will immediately restore the same data, but only for completed transactions
806
- // IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking
807
- if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) {
808
- const pendingSyncKeys = new Set<TKey>()
809
-
810
- // Collect keys from pending sync operations
811
- for (const transaction of this.pendingSyncedTransactions) {
812
- for (const operation of transaction.operations) {
813
- pendingSyncKeys.add(operation.key as TKey)
814
- }
815
- }
816
-
817
- // Only filter out delete events for keys that:
818
- // 1. Have pending sync operations AND
819
- // 2. Are from completed transactions (being cleaned up)
820
- const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
821
- if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
822
- // Check if this delete is from clearing optimistic state of completed transactions
823
- // We can infer this by checking if we have no remaining optimistic mutations for this key
824
- const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
825
- tx.mutations.some(
826
- (m) => m.collection === this && m.key === event.key
827
- )
828
- )
829
-
830
- if (!hasActiveOptimisticMutation) {
831
- return false // Skip this delete event as sync will restore the data
832
- }
833
- }
834
- return true
835
- })
836
-
837
- // Update indexes for the filtered events
838
- if (filteredEvents.length > 0) {
839
- this.updateIndexes(filteredEvents)
840
- }
841
- this.emitEvents(filteredEvents, triggeredByUserAction)
842
- } else {
843
- // Update indexes for all events
844
- if (filteredEventsBySyncStatus.length > 0) {
845
- this.updateIndexes(filteredEventsBySyncStatus)
846
- }
847
- // Emit all events if no pending sync transactions
848
- this.emitEvents(filteredEventsBySyncStatus, triggeredByUserAction)
849
- }
850
- }
851
-
852
- /**
853
- * Calculate the current size based on synced data and optimistic changes
854
- */
855
- private calculateSize(): number {
856
- const syncedSize = this.syncedData.size
857
- const deletesFromSynced = Array.from(this.optimisticDeletes).filter(
858
- (key) => this.syncedData.has(key) && !this.optimisticUpserts.has(key)
859
- ).length
860
- const upsertsNotInSynced = Array.from(this.optimisticUpserts.keys()).filter(
861
- (key) => !this.syncedData.has(key)
862
- ).length
863
-
864
- return syncedSize - deletesFromSynced + upsertsNotInSynced
865
- }
866
-
867
- /**
868
- * Collect events for optimistic changes
869
- */
870
- private collectOptimisticChanges(
871
- previousUpserts: Map<TKey, TOutput>,
872
- previousDeletes: Set<TKey>,
873
- events: Array<ChangeMessage<TOutput, TKey>>
874
- ): void {
875
- const allKeys = new Set([
876
- ...previousUpserts.keys(),
877
- ...this.optimisticUpserts.keys(),
878
- ...previousDeletes,
879
- ...this.optimisticDeletes,
880
- ])
881
-
882
- for (const key of allKeys) {
883
- const currentValue = this.get(key)
884
- const previousValue = this.getPreviousValue(
885
- key,
886
- previousUpserts,
887
- previousDeletes
888
- )
889
-
890
- if (previousValue !== undefined && currentValue === undefined) {
891
- events.push({ type: `delete`, key, value: previousValue })
892
- } else if (previousValue === undefined && currentValue !== undefined) {
893
- events.push({ type: `insert`, key, value: currentValue })
894
- } else if (
895
- previousValue !== undefined &&
896
- currentValue !== undefined &&
897
- previousValue !== currentValue
898
- ) {
899
- events.push({
900
- type: `update`,
901
- key,
902
- value: currentValue,
903
- previousValue,
904
- })
905
- }
906
- }
907
- }
908
-
909
- /**
910
- * Get the previous value for a key given previous optimistic state
911
- */
912
- private getPreviousValue(
913
- key: TKey,
914
- previousUpserts: Map<TKey, TOutput>,
915
- previousDeletes: Set<TKey>
916
- ): TOutput | undefined {
917
- if (previousDeletes.has(key)) {
918
- return undefined
919
- }
920
- if (previousUpserts.has(key)) {
921
- return previousUpserts.get(key)
922
- }
923
- return this.syncedData.get(key)
924
- }
925
-
926
- /**
927
- * Emit an empty ready event to notify subscribers that the collection is ready
928
- * This bypasses the normal empty array check in emitEvents
929
- */
930
- private emitEmptyReadyEvent(): void {
931
- // Emit empty array directly to all listeners
932
- for (const listener of this.changeListeners) {
933
- listener([])
934
- }
935
- // Emit to key-specific listeners
936
- for (const [_key, keyListeners] of this.changeKeyListeners) {
937
- for (const listener of keyListeners) {
938
- listener([])
939
- }
940
- }
941
- }
942
-
943
- /**
944
- * Emit events either immediately or batch them for later emission
945
- */
946
- private emitEvents(
947
- changes: Array<ChangeMessage<TOutput, TKey>>,
948
- forceEmit = false
949
- ): void {
950
- // Skip batching for user actions (forceEmit=true) to keep UI responsive
951
- if (this.shouldBatchEvents && !forceEmit) {
952
- // Add events to the batch
953
- this.batchedEvents.push(...changes)
954
- return
955
- }
956
-
957
- // Either we're not batching, or we're forcing emission (user action or ending batch cycle)
958
- let eventsToEmit = changes
959
-
960
- // If we have batched events and this is a forced emit, combine them
961
- if (this.batchedEvents.length > 0 && forceEmit) {
962
- eventsToEmit = [...this.batchedEvents, ...changes]
963
- this.batchedEvents = []
964
- this.shouldBatchEvents = false
965
- }
966
-
967
- if (eventsToEmit.length === 0) return
968
-
969
- // Emit to all listeners
970
- for (const listener of this.changeListeners) {
971
- listener(eventsToEmit)
972
- }
973
-
974
- // Emit to key-specific listeners
975
- if (this.changeKeyListeners.size > 0) {
976
- // Group changes by key, but only for keys that have listeners
977
- const changesByKey = new Map<TKey, Array<ChangeMessage<TOutput, TKey>>>()
978
- for (const change of eventsToEmit) {
979
- if (this.changeKeyListeners.has(change.key)) {
980
- if (!changesByKey.has(change.key)) {
981
- changesByKey.set(change.key, [])
982
- }
983
- changesByKey.get(change.key)!.push(change)
984
- }
985
- }
986
-
987
- // Emit batched changes to each key's listeners
988
- for (const [key, keyChanges] of changesByKey) {
989
- const keyListeners = this.changeKeyListeners.get(key)!
990
- for (const listener of keyListeners) {
991
- listener(keyChanges)
992
- }
993
- }
994
- }
995
- }
996
-
997
- /**
998
- * Get the current value for a key (virtual derived state)
999
- */
1000
- public get(key: TKey): TOutput | undefined {
1001
- // Check if optimistically deleted
1002
- if (this.optimisticDeletes.has(key)) {
1003
- return undefined
1004
- }
1005
-
1006
- // Check optimistic upserts first
1007
- if (this.optimisticUpserts.has(key)) {
1008
- return this.optimisticUpserts.get(key)
1009
- }
1010
-
1011
- // Fall back to synced data
1012
- return this.syncedData.get(key)
1013
- }
1014
-
1015
- /**
1016
- * Check if a key exists in the collection (virtual derived state)
1017
- */
1018
- public has(key: TKey): boolean {
1019
- // Check if optimistically deleted
1020
- if (this.optimisticDeletes.has(key)) {
1021
- return false
1022
- }
1023
-
1024
- // Check optimistic upserts first
1025
- if (this.optimisticUpserts.has(key)) {
1026
- return true
1027
- }
1028
-
1029
- // Fall back to synced data
1030
- return this.syncedData.has(key)
1031
- }
1032
-
1033
- /**
1034
- * Get the current size of the collection (cached)
1035
- */
1036
- public get size(): number {
1037
- return this._size
1038
- }
1039
-
1040
- /**
1041
- * Get all keys (virtual derived state)
1042
- */
1043
- public *keys(): IterableIterator<TKey> {
1044
- // Yield keys from synced data, skipping any that are deleted.
1045
- for (const key of this.syncedData.keys()) {
1046
- if (!this.optimisticDeletes.has(key)) {
1047
- yield key
1048
- }
1049
- }
1050
- // Yield keys from upserts that were not already in synced data.
1051
- for (const key of this.optimisticUpserts.keys()) {
1052
- if (!this.syncedData.has(key) && !this.optimisticDeletes.has(key)) {
1053
- // The optimisticDeletes check is technically redundant if inserts/updates always remove from deletes,
1054
- // but it's safer to keep it.
1055
- yield key
1056
- }
1057
- }
1058
- }
1059
-
1060
- /**
1061
- * Get all values (virtual derived state)
1062
- */
1063
- public *values(): IterableIterator<TOutput> {
1064
- for (const key of this.keys()) {
1065
- const value = this.get(key)
1066
- if (value !== undefined) {
1067
- yield value
1068
- }
1069
- }
1070
- }
1071
-
1072
- /**
1073
- * Get all entries (virtual derived state)
1074
- */
1075
- public *entries(): IterableIterator<[TKey, TOutput]> {
1076
- for (const key of this.keys()) {
1077
- const value = this.get(key)
1078
- if (value !== undefined) {
1079
- yield [key, value]
1080
- }
1081
- }
1082
- }
1083
-
1084
- /**
1085
- * Get all entries (virtual derived state)
1086
- */
1087
- public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> {
1088
- for (const [key, value] of this.entries()) {
1089
- yield [key, value]
1090
- }
1091
- }
1092
-
1093
- /**
1094
- * Execute a callback for each entry in the collection
1095
- */
1096
- public forEach(
1097
- callbackfn: (value: TOutput, key: TKey, index: number) => void
1098
- ): void {
1099
- let index = 0
1100
- for (const [key, value] of this.entries()) {
1101
- callbackfn(value, key, index++)
1102
- }
1103
- }
1104
-
1105
- /**
1106
- * Create a new array with the results of calling a function for each entry in the collection
1107
- */
1108
- public map<U>(
1109
- callbackfn: (value: TOutput, key: TKey, index: number) => U
1110
- ): Array<U> {
1111
- const result: Array<U> = []
1112
- let index = 0
1113
- for (const [key, value] of this.entries()) {
1114
- result.push(callbackfn(value, key, index++))
1115
- }
1116
- return result
1117
- }
1118
-
1119
- /**
1120
- * Attempts to commit pending synced transactions if there are no active transactions
1121
- * This method processes operations from pending transactions and applies them to the synced data
1122
- */
1123
- commitPendingTransactions = () => {
1124
- // Check if there are any persisting transaction
1125
- let hasPersistingTransaction = false
1126
- for (const transaction of this.transactions.values()) {
1127
- if (transaction.state === `persisting`) {
1128
- hasPersistingTransaction = true
1129
- break
1130
- }
1131
- }
1132
-
1133
- // pending synced transactions could be either `committed` or still open.
1134
- // we only want to process `committed` transactions here
1135
- const {
1136
- committedSyncedTransactions,
1137
- uncommittedSyncedTransactions,
1138
- hasTruncateSync,
1139
- } = this.pendingSyncedTransactions.reduce(
1140
- (acc, t) => {
1141
- if (t.committed) {
1142
- acc.committedSyncedTransactions.push(t)
1143
- if (t.truncate === true) {
1144
- acc.hasTruncateSync = true
1145
- }
1146
- } else {
1147
- acc.uncommittedSyncedTransactions.push(t)
1148
- }
1149
- return acc
1150
- },
1151
- {
1152
- committedSyncedTransactions: [] as Array<
1153
- PendingSyncedTransaction<TOutput>
1154
- >,
1155
- uncommittedSyncedTransactions: [] as Array<
1156
- PendingSyncedTransaction<TOutput>
1157
- >,
1158
- hasTruncateSync: false,
1159
- }
1160
- )
1161
-
1162
- if (!hasPersistingTransaction || hasTruncateSync) {
1163
- // Set flag to prevent redundant optimistic state recalculations
1164
- this.isCommittingSyncTransactions = true
1165
-
1166
- // First collect all keys that will be affected by sync operations
1167
- const changedKeys = new Set<TKey>()
1168
- for (const transaction of committedSyncedTransactions) {
1169
- for (const operation of transaction.operations) {
1170
- changedKeys.add(operation.key as TKey)
1171
- }
1172
- }
1173
-
1174
- // Use pre-captured state if available (from optimistic scenarios),
1175
- // otherwise capture current state (for pure sync scenarios)
1176
- let currentVisibleState = this.preSyncVisibleState
1177
- if (currentVisibleState.size === 0) {
1178
- // No pre-captured state, capture it now for pure sync operations
1179
- currentVisibleState = new Map<TKey, TOutput>()
1180
- for (const key of changedKeys) {
1181
- const currentValue = this.get(key)
1182
- if (currentValue !== undefined) {
1183
- currentVisibleState.set(key, currentValue)
1184
- }
1185
- }
1186
- }
1187
-
1188
- const events: Array<ChangeMessage<TOutput, TKey>> = []
1189
- const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
1190
-
1191
- for (const transaction of committedSyncedTransactions) {
1192
- // Handle truncate operations first
1193
- if (transaction.truncate) {
1194
- // TRUNCATE PHASE
1195
- // 1) Emit a delete for every currently-synced key so downstream listeners/indexes
1196
- // observe a clear-before-rebuild. We intentionally skip keys already in
1197
- // optimisticDeletes because their delete was previously emitted by the user.
1198
- for (const key of this.syncedData.keys()) {
1199
- if (this.optimisticDeletes.has(key)) continue
1200
- const previousValue =
1201
- this.optimisticUpserts.get(key) || this.syncedData.get(key)
1202
- if (previousValue !== undefined) {
1203
- events.push({ type: `delete`, key, value: previousValue })
1204
- }
1205
- }
1206
-
1207
- // 2) Clear the authoritative synced base. Subsequent server ops in this
1208
- // same commit will rebuild the base atomically.
1209
- this.syncedData.clear()
1210
- this.syncedMetadata.clear()
1211
- this.syncedKeys.clear()
1212
-
1213
- // 3) Clear currentVisibleState for truncated keys to ensure subsequent operations
1214
- // are compared against the post-truncate state (undefined) rather than pre-truncate state
1215
- // This ensures that re-inserted keys are emitted as INSERT events, not UPDATE events
1216
- for (const key of changedKeys) {
1217
- currentVisibleState.delete(key)
1218
- }
1219
- }
1220
-
1221
- for (const operation of transaction.operations) {
1222
- const key = operation.key as TKey
1223
- this.syncedKeys.add(key)
1224
-
1225
- // Update metadata
1226
- switch (operation.type) {
1227
- case `insert`:
1228
- this.syncedMetadata.set(key, operation.metadata)
1229
- break
1230
- case `update`:
1231
- this.syncedMetadata.set(
1232
- key,
1233
- Object.assign(
1234
- {},
1235
- this.syncedMetadata.get(key),
1236
- operation.metadata
1237
- )
1238
- )
1239
- break
1240
- case `delete`:
1241
- this.syncedMetadata.delete(key)
1242
- break
1243
- }
1244
-
1245
- // Update synced data
1246
- switch (operation.type) {
1247
- case `insert`:
1248
- this.syncedData.set(key, operation.value)
1249
- break
1250
- case `update`: {
1251
- if (rowUpdateMode === `partial`) {
1252
- const updatedValue = Object.assign(
1253
- {},
1254
- this.syncedData.get(key),
1255
- operation.value
1256
- )
1257
- this.syncedData.set(key, updatedValue)
1258
- } else {
1259
- this.syncedData.set(key, operation.value)
1260
- }
1261
- break
1262
- }
1263
- case `delete`:
1264
- this.syncedData.delete(key)
1265
- break
1266
- }
1267
- }
1268
- }
1269
-
1270
- // After applying synced operations, if this commit included a truncate,
1271
- // re-apply optimistic mutations on top of the fresh synced base. This ensures
1272
- // the UI preserves local intent while respecting server rebuild semantics.
1273
- // Ordering: deletes (above) -> server ops (just applied) -> optimistic upserts.
1274
- if (hasTruncateSync) {
1275
- // Avoid duplicating keys that were inserted/updated by synced operations in this commit
1276
- const syncedInsertedOrUpdatedKeys = new Set<TKey>()
1277
- for (const t of committedSyncedTransactions) {
1278
- for (const op of t.operations) {
1279
- if (op.type === `insert` || op.type === `update`) {
1280
- syncedInsertedOrUpdatedKeys.add(op.key as TKey)
1281
- }
1282
- }
1283
- }
1284
-
1285
- // Build re-apply sets from ACTIVE optimistic transactions against the new synced base
1286
- // We do not copy maps; we compute intent directly from transactions to avoid drift.
1287
- const reapplyUpserts = new Map<TKey, TOutput>()
1288
- const reapplyDeletes = new Set<TKey>()
1289
-
1290
- for (const tx of this.transactions.values()) {
1291
- if ([`completed`, `failed`].includes(tx.state)) continue
1292
- for (const mutation of tx.mutations) {
1293
- if (mutation.collection !== this || !mutation.optimistic) continue
1294
- const key = mutation.key as TKey
1295
- switch (mutation.type) {
1296
- case `insert`:
1297
- reapplyUpserts.set(key, mutation.modified as TOutput)
1298
- reapplyDeletes.delete(key)
1299
- break
1300
- case `update`: {
1301
- const base = this.syncedData.get(key)
1302
- const next = base
1303
- ? (Object.assign({}, base, mutation.changes) as TOutput)
1304
- : (mutation.modified as TOutput)
1305
- reapplyUpserts.set(key, next)
1306
- reapplyDeletes.delete(key)
1307
- break
1308
- }
1309
- case `delete`:
1310
- reapplyUpserts.delete(key)
1311
- reapplyDeletes.add(key)
1312
- break
1313
- }
1314
- }
1315
- }
1316
-
1317
- // Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
1318
- // If the server also inserted/updated the same key in this batch, override that value
1319
- // with the optimistic value to preserve local intent.
1320
- for (const [key, value] of reapplyUpserts) {
1321
- if (reapplyDeletes.has(key)) continue
1322
- if (syncedInsertedOrUpdatedKeys.has(key)) {
1323
- let foundInsert = false
1324
- for (let i = events.length - 1; i >= 0; i--) {
1325
- const evt = events[i]!
1326
- if (evt.key === key && evt.type === `insert`) {
1327
- evt.value = value
1328
- foundInsert = true
1329
- break
1330
- }
1331
- }
1332
- if (!foundInsert) {
1333
- events.push({ type: `insert`, key, value })
1334
- }
1335
- } else {
1336
- events.push({ type: `insert`, key, value })
1337
- }
1338
- }
1339
-
1340
- // Finally, ensure we do NOT insert keys that have an outstanding optimistic delete.
1341
- if (events.length > 0 && reapplyDeletes.size > 0) {
1342
- const filtered: Array<ChangeMessage<TOutput, TKey>> = []
1343
- for (const evt of events) {
1344
- if (evt.type === `insert` && reapplyDeletes.has(evt.key)) {
1345
- continue
1346
- }
1347
- filtered.push(evt)
1348
- }
1349
- events.length = 0
1350
- events.push(...filtered)
1351
- }
1352
-
1353
- // Ensure listeners are active before emitting this critical batch
1354
- if (!this.isReady()) {
1355
- this.setStatus(`ready`)
1356
- }
1357
- }
1358
-
1359
- // Maintain optimistic state appropriately
1360
- // Clear optimistic state since sync operations will now provide the authoritative data.
1361
- // Any still-active user transactions will be re-applied below in recompute.
1362
- this.optimisticUpserts.clear()
1363
- this.optimisticDeletes.clear()
1364
-
1365
- // Reset flag and recompute optimistic state for any remaining active transactions
1366
- this.isCommittingSyncTransactions = false
1367
- for (const transaction of this.transactions.values()) {
1368
- if (![`completed`, `failed`].includes(transaction.state)) {
1369
- for (const mutation of transaction.mutations) {
1370
- if (mutation.collection === this && mutation.optimistic) {
1371
- switch (mutation.type) {
1372
- case `insert`:
1373
- case `update`:
1374
- this.optimisticUpserts.set(
1375
- mutation.key,
1376
- mutation.modified as TOutput
1377
- )
1378
- this.optimisticDeletes.delete(mutation.key)
1379
- break
1380
- case `delete`:
1381
- this.optimisticUpserts.delete(mutation.key)
1382
- this.optimisticDeletes.add(mutation.key)
1383
- break
1384
- }
1385
- }
1386
- }
1387
- }
1388
- }
1389
-
1390
- // Check for redundant sync operations that match completed optimistic operations
1391
- const completedOptimisticOps = new Map<TKey, any>()
1392
-
1393
- for (const transaction of this.transactions.values()) {
1394
- if (transaction.state === `completed`) {
1395
- for (const mutation of transaction.mutations) {
1396
- if (mutation.collection === this && changedKeys.has(mutation.key)) {
1397
- completedOptimisticOps.set(mutation.key, {
1398
- type: mutation.type,
1399
- value: mutation.modified,
1400
- })
1401
- }
1402
- }
1403
- }
1404
- }
1405
-
1406
- // Now check what actually changed in the final visible state
1407
- for (const key of changedKeys) {
1408
- const previousVisibleValue = currentVisibleState.get(key)
1409
- const newVisibleValue = this.get(key) // This returns the new derived state
1410
-
1411
- // Check if this sync operation is redundant with a completed optimistic operation
1412
- const completedOp = completedOptimisticOps.get(key)
1413
- const isRedundantSync =
1414
- completedOp &&
1415
- newVisibleValue !== undefined &&
1416
- deepEquals(completedOp.value, newVisibleValue)
1417
-
1418
- if (!isRedundantSync) {
1419
- if (
1420
- previousVisibleValue === undefined &&
1421
- newVisibleValue !== undefined
1422
- ) {
1423
- events.push({
1424
- type: `insert`,
1425
- key,
1426
- value: newVisibleValue,
1427
- })
1428
- } else if (
1429
- previousVisibleValue !== undefined &&
1430
- newVisibleValue === undefined
1431
- ) {
1432
- events.push({
1433
- type: `delete`,
1434
- key,
1435
- value: previousVisibleValue,
1436
- })
1437
- } else if (
1438
- previousVisibleValue !== undefined &&
1439
- newVisibleValue !== undefined &&
1440
- !deepEquals(previousVisibleValue, newVisibleValue)
1441
- ) {
1442
- events.push({
1443
- type: `update`,
1444
- key,
1445
- value: newVisibleValue,
1446
- previousValue: previousVisibleValue,
1447
- })
1448
- }
1449
- }
1450
- }
1451
-
1452
- // Update cached size after synced data changes
1453
- this._size = this.calculateSize()
1454
-
1455
- // Update indexes for all events before emitting
1456
- if (events.length > 0) {
1457
- this.updateIndexes(events)
1458
- }
1459
-
1460
- // End batching and emit all events (combines any batched events with sync events)
1461
- this.emitEvents(events, true)
1462
-
1463
- this.pendingSyncedTransactions = uncommittedSyncedTransactions
1464
-
1465
- // Clear the pre-sync state since sync operations are complete
1466
- this.preSyncVisibleState.clear()
1467
-
1468
- // Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
1469
- Promise.resolve().then(() => {
1470
- this.recentlySyncedKeys.clear()
1471
- })
1472
-
1473
- // Call any registered one-time commit listeners
1474
- if (!this.hasReceivedFirstCommit) {
1475
- this.hasReceivedFirstCommit = true
1476
- const callbacks = [...this.onFirstReadyCallbacks]
1477
- this.onFirstReadyCallbacks = []
1478
- callbacks.forEach((callback) => callback())
1479
- }
1480
- }
1481
- }
1482
-
1483
- /**
1484
- * Schedule cleanup of a transaction when it completes
1485
- * @private
1486
- */
1487
- private scheduleTransactionCleanup(transaction: Transaction<any>): void {
1488
- // Only schedule cleanup for transactions that aren't already completed
1489
- if (transaction.state === `completed`) {
1490
- this.transactions.delete(transaction.id)
1491
- return
1492
- }
1493
-
1494
- // Schedule cleanup when the transaction completes
1495
- transaction.isPersisted.promise
1496
- .then(() => {
1497
- // Transaction completed successfully, remove it immediately
1498
- this.transactions.delete(transaction.id)
1499
- })
1500
- .catch(() => {
1501
- // Transaction failed, but we want to keep failed transactions for reference
1502
- // so don't remove it.
1503
- // This empty catch block is necessary to prevent unhandled promise rejections.
1504
- })
1505
- }
1506
-
1507
- private ensureStandardSchema(schema: unknown): StandardSchema<TOutput> {
1508
- // If the schema already implements the standard-schema interface, return it
1509
- if (schema && `~standard` in (schema as {})) {
1510
- return schema as StandardSchema<TOutput>
1511
- }
1512
-
1513
- throw new InvalidSchemaError()
1514
- }
1515
-
1516
- public getKeyFromItem(item: TOutput): TKey {
1517
- return this.config.getKey(item)
1518
- }
1519
-
1520
- public generateGlobalKey(key: any, item: any): string {
1521
- if (typeof key === `undefined`) {
1522
- throw new UndefinedKeyError(item)
1523
- }
1524
-
1525
- return `KEY::${this.id}/${key}`
1526
- }
1527
-
1528
- /**
1529
- * Creates an index on a collection for faster queries.
1530
- * Indexes significantly improve query performance by allowing constant time lookups
1531
- * and logarithmic time range queries instead of full scans.
1532
- *
1533
- * @template TResolver - The type of the index resolver (constructor or async loader)
1534
- * @param indexCallback - Function that extracts the indexed value from each item
1535
- * @param config - Configuration including index type and type-specific options
1536
- * @returns An index proxy that provides access to the index when ready
1537
- *
1538
- * @example
1539
- * // Create a default B+ tree index
1540
- * const ageIndex = collection.createIndex((row) => row.age)
1541
- *
1542
- * // Create a ordered index with custom options
1543
- * const ageIndex = collection.createIndex((row) => row.age, {
1544
- * indexType: BTreeIndex,
1545
- * options: { compareFn: customComparator },
1546
- * name: 'age_btree'
1547
- * })
1548
- *
1549
- * // Create an async-loaded index
1550
- * const textIndex = collection.createIndex((row) => row.content, {
1551
- * indexType: async () => {
1552
- * const { FullTextIndex } = await import('./indexes/fulltext.js')
1553
- * return FullTextIndex
1554
- * },
1555
- * options: { language: 'en' }
1556
- * })
1557
- */
1558
- public createIndex<TResolver extends IndexResolver<TKey> = typeof BTreeIndex>(
1559
- indexCallback: (row: SingleRowRefProxy<TOutput>) => any,
1560
- config: IndexOptions<TResolver> = {}
1561
- ): IndexProxy<TKey> {
1562
- this.validateCollectionUsable(`createIndex`)
1563
-
1564
- const indexId = ++this.indexCounter
1565
- const singleRowRefProxy = createSingleRowRefProxy<TOutput>()
1566
- const indexExpression = indexCallback(singleRowRefProxy)
1567
- const expression = toExpression(indexExpression)
1568
-
1569
- // Default to BTreeIndex if no type specified
1570
- const resolver = config.indexType ?? (BTreeIndex as unknown as TResolver)
1571
-
1572
- // Create lazy wrapper
1573
- const lazyIndex = new LazyIndexWrapper<TKey>(
1574
- indexId,
1575
- expression,
1576
- config.name,
1577
- resolver,
1578
- config.options,
1579
- this.entries()
1580
- )
1581
-
1582
- this.lazyIndexes.set(indexId, lazyIndex)
1583
-
1584
- // For BTreeIndex, resolve immediately and synchronously
1585
- if ((resolver as unknown) === BTreeIndex) {
1586
- try {
1587
- const resolvedIndex = lazyIndex.getResolved()
1588
- this.resolvedIndexes.set(indexId, resolvedIndex)
1589
- } catch (error) {
1590
- console.warn(`Failed to resolve BTreeIndex:`, error)
1591
- }
1592
- } else if (typeof resolver === `function` && resolver.prototype) {
1593
- // Other synchronous constructors - resolve immediately
1594
- try {
1595
- const resolvedIndex = lazyIndex.getResolved()
1596
- this.resolvedIndexes.set(indexId, resolvedIndex)
1597
- } catch {
1598
- // Fallback to async resolution
1599
- this.resolveSingleIndex(indexId, lazyIndex).catch((error) => {
1600
- console.warn(`Failed to resolve single index:`, error)
1601
- })
1602
- }
1603
- } else if (this.isIndexesResolved) {
1604
- // Async loader but indexes are already resolved - resolve this one
1605
- this.resolveSingleIndex(indexId, lazyIndex).catch((error) => {
1606
- console.warn(`Failed to resolve single index:`, error)
1607
- })
1608
- }
1609
-
1610
- return new IndexProxy(indexId, lazyIndex)
1611
- }
1612
-
1613
- /**
1614
- * Resolve all lazy indexes (called when collection first syncs)
1615
- * @private
1616
- */
1617
- private async resolveAllIndexes(): Promise<void> {
1618
- if (this.isIndexesResolved) return
1619
-
1620
- const resolutionPromises = Array.from(this.lazyIndexes.entries()).map(
1621
- async ([indexId, lazyIndex]) => {
1622
- const resolvedIndex = await lazyIndex.resolve()
1623
-
1624
- // Build index with current data
1625
- resolvedIndex.build(this.entries())
1626
-
1627
- this.resolvedIndexes.set(indexId, resolvedIndex)
1628
- return { indexId, resolvedIndex }
1629
- }
1630
- )
1631
-
1632
- await Promise.all(resolutionPromises)
1633
- this.isIndexesResolved = true
1634
- }
1635
-
1636
- /**
1637
- * Resolve a single index immediately
1638
- * @private
1639
- */
1640
- private async resolveSingleIndex(
1641
- indexId: number,
1642
- lazyIndex: LazyIndexWrapper<TKey>
1643
- ): Promise<BaseIndex<TKey>> {
1644
- const resolvedIndex = await lazyIndex.resolve()
1645
- resolvedIndex.build(this.entries())
1646
- this.resolvedIndexes.set(indexId, resolvedIndex)
1647
- return resolvedIndex
1648
- }
1649
-
1650
- /**
1651
- * Get resolved indexes for query optimization
1652
- */
1653
- get indexes(): Map<number, BaseIndex<TKey>> {
1654
- return this.resolvedIndexes
1655
- }
1656
-
1657
- /**
1658
- * Updates all indexes when the collection changes
1659
- * @private
1660
- */
1661
- private updateIndexes(changes: Array<ChangeMessage<TOutput, TKey>>): void {
1662
- for (const index of this.resolvedIndexes.values()) {
1663
- for (const change of changes) {
1664
- switch (change.type) {
1665
- case `insert`:
1666
- index.add(change.key, change.value)
1667
- break
1668
- case `update`:
1669
- if (change.previousValue) {
1670
- index.update(change.key, change.previousValue, change.value)
1671
- } else {
1672
- index.add(change.key, change.value)
1673
- }
1674
- break
1675
- case `delete`:
1676
- index.remove(change.key, change.value)
1677
- break
1678
- }
1679
- }
1680
- }
1681
- }
1682
-
1683
- public validateData(
1684
- data: unknown,
1685
- type: `insert` | `update`,
1686
- key?: TKey
1687
- ): TOutput | never {
1688
- if (!this.config.schema) return data as TOutput
1689
-
1690
- const standardSchema = this.ensureStandardSchema(this.config.schema)
1691
-
1692
- // For updates, we need to merge with the existing data before validation
1693
- if (type === `update` && key) {
1694
- // Get the existing data for this key
1695
- const existingData = this.get(key)
1696
-
1697
- if (
1698
- existingData &&
1699
- data &&
1700
- typeof data === `object` &&
1701
- typeof existingData === `object`
1702
- ) {
1703
- // Merge the update with the existing data
1704
- const mergedData = Object.assign({}, existingData, data)
1705
-
1706
- // Validate the merged data
1707
- const result = standardSchema[`~standard`].validate(mergedData)
1708
-
1709
- // Ensure validation is synchronous
1710
- if (result instanceof Promise) {
1711
- throw new SchemaMustBeSynchronousError()
1712
- }
1713
-
1714
- // If validation fails, throw a SchemaValidationError with the issues
1715
- if (`issues` in result && result.issues) {
1716
- const typedIssues = result.issues.map((issue) => ({
1717
- message: issue.message,
1718
- path: issue.path?.map((p) => String(p)),
1719
- }))
1720
- throw new SchemaValidationError(type, typedIssues)
1721
- }
1722
-
1723
- // Extract only the modified keys from the validated result
1724
- const validatedMergedData = result.value as TOutput
1725
- const modifiedKeys = Object.keys(data)
1726
- const extractedChanges = Object.fromEntries(
1727
- modifiedKeys.map((k) => [k, validatedMergedData[k as keyof TOutput]])
1728
- ) as TOutput
1729
-
1730
- return extractedChanges
1731
- }
1732
- }
1733
-
1734
- // For inserts or updates without existing data, validate the data directly
1735
- const result = standardSchema[`~standard`].validate(data)
1736
-
1737
- // Ensure validation is synchronous
1738
- if (result instanceof Promise) {
1739
- throw new SchemaMustBeSynchronousError()
1740
- }
1741
-
1742
- // If validation fails, throw a SchemaValidationError with the issues
1743
- if (`issues` in result && result.issues) {
1744
- const typedIssues = result.issues.map((issue) => ({
1745
- message: issue.message,
1746
- path: issue.path?.map((p) => String(p)),
1747
- }))
1748
- throw new SchemaValidationError(type, typedIssues)
1749
- }
1750
-
1751
- return result.value as TOutput
1752
- }
1753
-
1754
- /**
1755
- * Inserts one or more items into the collection
1756
- * @param items - Single item or array of items to insert
1757
- * @param config - Optional configuration including metadata
1758
- * @returns A Transaction object representing the insert operation(s)
1759
- * @throws {SchemaValidationError} If the data fails schema validation
1760
- * @example
1761
- * // Insert a single todo (requires onInsert handler)
1762
- * const tx = collection.insert({ id: "1", text: "Buy milk", completed: false })
1763
- * await tx.isPersisted.promise
1764
- *
1765
- * @example
1766
- * // Insert multiple todos at once
1767
- * const tx = collection.insert([
1768
- * { id: "1", text: "Buy milk", completed: false },
1769
- * { id: "2", text: "Walk dog", completed: true }
1770
- * ])
1771
- * await tx.isPersisted.promise
1772
- *
1773
- * @example
1774
- * // Insert with metadata
1775
- * const tx = collection.insert({ id: "1", text: "Buy groceries" },
1776
- * { metadata: { source: "mobile-app" } }
1777
- * )
1778
- * await tx.isPersisted.promise
1779
- *
1780
- * @example
1781
- * // Handle errors
1782
- * try {
1783
- * const tx = collection.insert({ id: "1", text: "New item" })
1784
- * await tx.isPersisted.promise
1785
- * console.log('Insert successful')
1786
- * } catch (error) {
1787
- * console.log('Insert failed:', error)
1788
- * }
1789
- */
1790
- insert = (data: TInput | Array<TInput>, config?: InsertConfig) => {
1791
- this.validateCollectionUsable(`insert`)
1792
- const ambientTransaction = getActiveTransaction()
1793
-
1794
- // If no ambient transaction exists, check for an onInsert handler early
1795
- if (!ambientTransaction && !this.config.onInsert) {
1796
- throw new MissingInsertHandlerError()
1797
- }
1798
-
1799
- const items = Array.isArray(data) ? data : [data]
1800
- const mutations: Array<PendingMutation<TOutput>> = []
1801
-
1802
- // Create mutations for each item
1803
- items.forEach((item) => {
1804
- // Validate the data against the schema if one exists
1805
- const validatedData = this.validateData(item, `insert`)
1806
-
1807
- // Check if an item with this ID already exists in the collection
1808
- const key = this.getKeyFromItem(validatedData)
1809
- if (this.has(key)) {
1810
- throw new DuplicateKeyError(key)
1811
- }
1812
- const globalKey = this.generateGlobalKey(key, item)
1813
-
1814
- const mutation: PendingMutation<TOutput, `insert`> = {
1815
- mutationId: crypto.randomUUID(),
1816
- original: {},
1817
- modified: validatedData,
1818
- // Pick the values from validatedData based on what's passed in - this is for cases
1819
- // where a schema has default values. The validated data has the extra default
1820
- // values but for changes, we just want to show the data that was actually passed in.
1821
- changes: Object.fromEntries(
1822
- Object.keys(item).map((k) => [
1823
- k,
1824
- validatedData[k as keyof typeof validatedData],
1825
- ])
1826
- ) as TInput,
1827
- globalKey,
1828
- key,
1829
- metadata: config?.metadata as unknown,
1830
- syncMetadata: this.config.sync.getSyncMetadata?.() || {},
1831
- optimistic: config?.optimistic ?? true,
1832
- type: `insert`,
1833
- createdAt: new Date(),
1834
- updatedAt: new Date(),
1835
- collection: this,
1836
- }
1837
-
1838
- mutations.push(mutation)
1839
- })
1840
-
1841
- // If an ambient transaction exists, use it
1842
- if (ambientTransaction) {
1843
- ambientTransaction.applyMutations(mutations)
1844
-
1845
- this.transactions.set(ambientTransaction.id, ambientTransaction)
1846
- this.scheduleTransactionCleanup(ambientTransaction)
1847
- this.recomputeOptimisticState(true)
1848
-
1849
- return ambientTransaction
1850
- } else {
1851
- // Create a new transaction with a mutation function that calls the onInsert handler
1852
- const directOpTransaction = createTransaction<TOutput>({
1853
- mutationFn: async (params) => {
1854
- // Call the onInsert handler with the transaction and collection
1855
- return await this.config.onInsert!({
1856
- transaction:
1857
- params.transaction as unknown as TransactionWithMutations<
1858
- TOutput,
1859
- `insert`
1860
- >,
1861
- collection: this as unknown as Collection<TOutput, TKey, TUtils>,
1862
- })
1863
- },
1864
- })
1865
-
1866
- // Apply mutations to the new transaction
1867
- directOpTransaction.applyMutations(mutations)
1868
- directOpTransaction.commit()
1869
-
1870
- // Add the transaction to the collection's transactions store
1871
- this.transactions.set(directOpTransaction.id, directOpTransaction)
1872
- this.scheduleTransactionCleanup(directOpTransaction)
1873
- this.recomputeOptimisticState(true)
1874
-
1875
- return directOpTransaction
1876
- }
1877
- }
1878
-
1879
- /**
1880
- * Updates one or more items in the collection using a callback function
1881
- * @param keys - Single key or array of keys to update
1882
- * @param configOrCallback - Either update configuration or update callback
1883
- * @param maybeCallback - Update callback if config was provided
1884
- * @returns A Transaction object representing the update operation(s)
1885
- * @throws {SchemaValidationError} If the updated data fails schema validation
1886
- * @example
1887
- * // Update single item by key
1888
- * const tx = collection.update("todo-1", (draft) => {
1889
- * draft.completed = true
1890
- * })
1891
- * await tx.isPersisted.promise
1892
- *
1893
- * @example
1894
- * // Update multiple items
1895
- * const tx = collection.update(["todo-1", "todo-2"], (drafts) => {
1896
- * drafts.forEach(draft => { draft.completed = true })
1897
- * })
1898
- * await tx.isPersisted.promise
1899
- *
1900
- * @example
1901
- * // Update with metadata
1902
- * const tx = collection.update("todo-1",
1903
- * { metadata: { reason: "user update" } },
1904
- * (draft) => { draft.text = "Updated text" }
1905
- * )
1906
- * await tx.isPersisted.promise
1907
- *
1908
- * @example
1909
- * // Handle errors
1910
- * try {
1911
- * const tx = collection.update("item-1", draft => { draft.value = "new" })
1912
- * await tx.isPersisted.promise
1913
- * console.log('Update successful')
1914
- * } catch (error) {
1915
- * console.log('Update failed:', error)
1916
- * }
1917
- */
1918
-
1919
- // Overload 1: Update multiple items with a callback
1920
- update(
1921
- key: Array<TKey | unknown>,
1922
- callback: (drafts: Array<WritableDeep<TInput>>) => void
1923
- ): TransactionType
1924
-
1925
- // Overload 2: Update multiple items with config and a callback
1926
- update(
1927
- keys: Array<TKey | unknown>,
1928
- config: OperationConfig,
1929
- callback: (drafts: Array<WritableDeep<TInput>>) => void
1930
- ): TransactionType
1931
-
1932
- // Overload 3: Update a single item with a callback
1933
- update(
1934
- id: TKey | unknown,
1935
- callback: (draft: WritableDeep<TInput>) => void
1936
- ): TransactionType
1937
-
1938
- // Overload 4: Update a single item with config and a callback
1939
- update(
1940
- id: TKey | unknown,
1941
- config: OperationConfig,
1942
- callback: (draft: WritableDeep<TInput>) => void
1943
- ): TransactionType
1944
-
1945
- update(
1946
- keys: (TKey | unknown) | Array<TKey | unknown>,
1947
- configOrCallback:
1948
- | ((draft: WritableDeep<TInput>) => void)
1949
- | ((drafts: Array<WritableDeep<TInput>>) => void)
1950
- | OperationConfig,
1951
- maybeCallback?:
1952
- | ((draft: WritableDeep<TInput>) => void)
1953
- | ((drafts: Array<WritableDeep<TInput>>) => void)
1954
- ) {
1955
- if (typeof keys === `undefined`) {
1956
- throw new MissingUpdateArgumentError()
1957
- }
1958
-
1959
- this.validateCollectionUsable(`update`)
1960
-
1961
- const ambientTransaction = getActiveTransaction()
1962
-
1963
- // If no ambient transaction exists, check for an onUpdate handler early
1964
- if (!ambientTransaction && !this.config.onUpdate) {
1965
- throw new MissingUpdateHandlerError()
1966
- }
1967
-
1968
- const isArray = Array.isArray(keys)
1969
- const keysArray = isArray ? keys : [keys]
1970
-
1971
- if (isArray && keysArray.length === 0) {
1972
- throw new NoKeysPassedToUpdateError()
1973
- }
1974
-
1975
- const callback =
1976
- typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
1977
- const config =
1978
- typeof configOrCallback === `function` ? {} : configOrCallback
1979
-
1980
- // Get the current objects or empty objects if they don't exist
1981
- const currentObjects = keysArray.map((key) => {
1982
- const item = this.get(key)
1983
- if (!item) {
1984
- throw new UpdateKeyNotFoundError(key)
1985
- }
1986
-
1987
- return item
1988
- }) as unknown as Array<TInput>
1989
-
1990
- let changesArray
1991
- if (isArray) {
1992
- // Use the proxy to track changes for all objects
1993
- changesArray = withArrayChangeTracking(
1994
- currentObjects,
1995
- callback as (draft: Array<TInput>) => void
1996
- )
1997
- } else {
1998
- const result = withChangeTracking(
1999
- currentObjects[0]!,
2000
- callback as (draft: TInput) => void
2001
- )
2002
- changesArray = [result]
2003
- }
2004
-
2005
- // Create mutations for each object that has changes
2006
- const mutations: Array<PendingMutation<TOutput, `update`, this>> = keysArray
2007
- .map((key, index) => {
2008
- const itemChanges = changesArray[index] // User-provided changes for this specific item
2009
-
2010
- // Skip items with no changes
2011
- if (!itemChanges || Object.keys(itemChanges).length === 0) {
2012
- return null
2013
- }
2014
-
2015
- const originalItem = currentObjects[index] as unknown as TOutput
2016
- // Validate the user-provided changes for this item
2017
- const validatedUpdatePayload = this.validateData(
2018
- itemChanges,
2019
- `update`,
2020
- key
2021
- )
2022
-
2023
- // Construct the full modified item by applying the validated update payload to the original item
2024
- const modifiedItem = Object.assign(
2025
- {},
2026
- originalItem,
2027
- validatedUpdatePayload
2028
- )
2029
-
2030
- // Check if the ID of the item is being changed
2031
- const originalItemId = this.getKeyFromItem(originalItem)
2032
- const modifiedItemId = this.getKeyFromItem(modifiedItem)
2033
-
2034
- if (originalItemId !== modifiedItemId) {
2035
- throw new KeyUpdateNotAllowedError(originalItemId, modifiedItemId)
2036
- }
2037
-
2038
- const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem)
2039
-
2040
- return {
2041
- mutationId: crypto.randomUUID(),
2042
- original: originalItem,
2043
- modified: modifiedItem,
2044
- // Pick the values from modifiedItem based on what's passed in - this is for cases
2045
- // where a schema has default values or transforms. The modified data has the extra
2046
- // default or transformed values but for changes, we just want to show the data that
2047
- // was actually passed in.
2048
- changes: Object.fromEntries(
2049
- Object.keys(itemChanges).map((k) => [
2050
- k,
2051
- modifiedItem[k as keyof typeof modifiedItem],
2052
- ])
2053
- ) as TInput,
2054
- globalKey,
2055
- key,
2056
- metadata: config.metadata as unknown,
2057
- syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
2058
- string,
2059
- unknown
2060
- >,
2061
- optimistic: config.optimistic ?? true,
2062
- type: `update`,
2063
- createdAt: new Date(),
2064
- updatedAt: new Date(),
2065
- collection: this,
2066
- }
2067
- })
2068
- .filter(Boolean) as Array<PendingMutation<TOutput, `update`, this>>
2069
-
2070
- // If no changes were made, return an empty transaction early
2071
- if (mutations.length === 0) {
2072
- const emptyTransaction = createTransaction({
2073
- mutationFn: async () => {},
2074
- })
2075
- emptyTransaction.commit()
2076
- // Schedule cleanup for empty transaction
2077
- this.scheduleTransactionCleanup(emptyTransaction)
2078
- return emptyTransaction
2079
- }
2080
-
2081
- // If an ambient transaction exists, use it
2082
- if (ambientTransaction) {
2083
- ambientTransaction.applyMutations(mutations)
2084
-
2085
- this.transactions.set(ambientTransaction.id, ambientTransaction)
2086
- this.scheduleTransactionCleanup(ambientTransaction)
2087
- this.recomputeOptimisticState(true)
2088
-
2089
- return ambientTransaction
2090
- }
2091
-
2092
- // No need to check for onUpdate handler here as we've already checked at the beginning
2093
-
2094
- // Create a new transaction with a mutation function that calls the onUpdate handler
2095
- const directOpTransaction = createTransaction<TOutput>({
2096
- mutationFn: async (params) => {
2097
- // Call the onUpdate handler with the transaction and collection
2098
- return this.config.onUpdate!({
2099
- transaction:
2100
- params.transaction as unknown as TransactionWithMutations<
2101
- TOutput,
2102
- `update`
2103
- >,
2104
- collection: this as unknown as Collection<TOutput, TKey, TUtils>,
2105
- })
2106
- },
2107
- })
2108
-
2109
- // Apply mutations to the new transaction
2110
- directOpTransaction.applyMutations(mutations)
2111
- directOpTransaction.commit()
2112
-
2113
- // Add the transaction to the collection's transactions store
2114
-
2115
- this.transactions.set(directOpTransaction.id, directOpTransaction)
2116
- this.scheduleTransactionCleanup(directOpTransaction)
2117
- this.recomputeOptimisticState(true)
2118
-
2119
- return directOpTransaction
2120
- }
2121
-
2122
- /**
2123
- * Deletes one or more items from the collection
2124
- * @param keys - Single key or array of keys to delete
2125
- * @param config - Optional configuration including metadata
2126
- * @returns A Transaction object representing the delete operation(s)
2127
- * @example
2128
- * // Delete a single item
2129
- * const tx = collection.delete("todo-1")
2130
- * await tx.isPersisted.promise
2131
- *
2132
- * @example
2133
- * // Delete multiple items
2134
- * const tx = collection.delete(["todo-1", "todo-2"])
2135
- * await tx.isPersisted.promise
2136
- *
2137
- * @example
2138
- * // Delete with metadata
2139
- * const tx = collection.delete("todo-1", { metadata: { reason: "completed" } })
2140
- * await tx.isPersisted.promise
2141
- *
2142
- * @example
2143
- * // Handle errors
2144
- * try {
2145
- * const tx = collection.delete("item-1")
2146
- * await tx.isPersisted.promise
2147
- * console.log('Delete successful')
2148
- * } catch (error) {
2149
- * console.log('Delete failed:', error)
2150
- * }
2151
- */
2152
- delete = (
2153
- keys: Array<TKey> | TKey,
2154
- config?: OperationConfig
2155
- ): TransactionType<any> => {
2156
- this.validateCollectionUsable(`delete`)
2157
-
2158
- const ambientTransaction = getActiveTransaction()
2159
-
2160
- // If no ambient transaction exists, check for an onDelete handler early
2161
- if (!ambientTransaction && !this.config.onDelete) {
2162
- throw new MissingDeleteHandlerError()
2163
- }
2164
-
2165
- if (Array.isArray(keys) && keys.length === 0) {
2166
- throw new NoKeysPassedToDeleteError()
2167
- }
2168
-
2169
- const keysArray = Array.isArray(keys) ? keys : [keys]
2170
- const mutations: Array<PendingMutation<TOutput, `delete`, this>> = []
2171
-
2172
- for (const key of keysArray) {
2173
- if (!this.has(key)) {
2174
- throw new DeleteKeyNotFoundError(key)
2175
- }
2176
- const globalKey = this.generateGlobalKey(key, this.get(key)!)
2177
- const mutation: PendingMutation<TOutput, `delete`, this> = {
2178
- mutationId: crypto.randomUUID(),
2179
- original: this.get(key)!,
2180
- modified: this.get(key)!,
2181
- changes: this.get(key)!,
2182
- globalKey,
2183
- key,
2184
- metadata: config?.metadata as unknown,
2185
- syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
2186
- string,
2187
- unknown
2188
- >,
2189
- optimistic: config?.optimistic ?? true,
2190
- type: `delete`,
2191
- createdAt: new Date(),
2192
- updatedAt: new Date(),
2193
- collection: this,
2194
- }
2195
-
2196
- mutations.push(mutation)
2197
- }
2198
-
2199
- // If an ambient transaction exists, use it
2200
- if (ambientTransaction) {
2201
- ambientTransaction.applyMutations(mutations)
2202
-
2203
- this.transactions.set(ambientTransaction.id, ambientTransaction)
2204
- this.scheduleTransactionCleanup(ambientTransaction)
2205
- this.recomputeOptimisticState(true)
2206
-
2207
- return ambientTransaction
2208
- }
2209
-
2210
- // Create a new transaction with a mutation function that calls the onDelete handler
2211
- const directOpTransaction = createTransaction<TOutput>({
2212
- autoCommit: true,
2213
- mutationFn: async (params) => {
2214
- // Call the onDelete handler with the transaction and collection
2215
- return this.config.onDelete!({
2216
- transaction:
2217
- params.transaction as unknown as TransactionWithMutations<
2218
- TOutput,
2219
- `delete`
2220
- >,
2221
- collection: this as unknown as Collection<TOutput, TKey, TUtils>,
2222
- })
2223
- },
2224
- })
2225
-
2226
- // Apply mutations to the new transaction
2227
- directOpTransaction.applyMutations(mutations)
2228
- directOpTransaction.commit()
2229
-
2230
- this.transactions.set(directOpTransaction.id, directOpTransaction)
2231
- this.scheduleTransactionCleanup(directOpTransaction)
2232
- this.recomputeOptimisticState(true)
2233
-
2234
- return directOpTransaction
2235
- }
2236
-
2237
- /**
2238
- * Gets the current state of the collection as a Map
2239
- * @returns Map containing all items in the collection, with keys as identifiers
2240
- * @example
2241
- * const itemsMap = collection.state
2242
- * console.log(`Collection has ${itemsMap.size} items`)
2243
- *
2244
- * for (const [key, item] of itemsMap) {
2245
- * console.log(`${key}: ${item.title}`)
2246
- * }
2247
- *
2248
- * // Check if specific item exists
2249
- * if (itemsMap.has("todo-1")) {
2250
- * console.log("Todo 1 exists:", itemsMap.get("todo-1"))
2251
- * }
2252
- */
2253
- get state() {
2254
- const result = new Map<TKey, TOutput>()
2255
- for (const [key, value] of this.entries()) {
2256
- result.set(key, value)
2257
- }
2258
- return result
2259
- }
2260
-
2261
- /**
2262
- * Gets the current state of the collection as a Map, but only resolves when data is available
2263
- * Waits for the first sync commit to complete before resolving
2264
- *
2265
- * @returns Promise that resolves to a Map containing all items in the collection
2266
- */
2267
- stateWhenReady(): Promise<Map<TKey, TOutput>> {
2268
- // If we already have data or collection is ready, resolve immediately
2269
- if (this.size > 0 || this.isReady()) {
2270
- return Promise.resolve(this.state)
2271
- }
2272
-
2273
- // Use preload to ensure the collection starts loading, then return the state
2274
- return this.preload().then(() => this.state)
2275
- }
2276
-
2277
- /**
2278
- * Gets the current state of the collection as an Array
2279
- *
2280
- * @returns An Array containing all items in the collection
2281
- */
2282
- get toArray() {
2283
- return Array.from(this.values())
2284
- }
2285
-
2286
- /**
2287
- * Gets the current state of the collection as an Array, but only resolves when data is available
2288
- * Waits for the first sync commit to complete before resolving
2289
- *
2290
- * @returns Promise that resolves to an Array containing all items in the collection
2291
- */
2292
- toArrayWhenReady(): Promise<Array<TOutput>> {
2293
- // If we already have data or collection is ready, resolve immediately
2294
- if (this.size > 0 || this.isReady()) {
2295
- return Promise.resolve(this.toArray)
2296
- }
2297
-
2298
- // Use preload to ensure the collection starts loading, then return the array
2299
- return this.preload().then(() => this.toArray)
2300
- }
2301
-
2302
- /**
2303
- * Returns the current state of the collection as an array of changes
2304
- * @param options - Options including optional where filter
2305
- * @returns An array of changes
2306
- * @example
2307
- * // Get all items as changes
2308
- * const allChanges = collection.currentStateAsChanges()
2309
- *
2310
- * // Get only items matching a condition
2311
- * const activeChanges = collection.currentStateAsChanges({
2312
- * where: (row) => row.status === 'active'
2313
- * })
2314
- *
2315
- * // Get only items using a pre-compiled expression
2316
- * const activeChanges = collection.currentStateAsChanges({
2317
- * whereExpression: eq(row.status, 'active')
2318
- * })
2319
- */
2320
- public currentStateAsChanges(
2321
- options: CurrentStateAsChangesOptions<TOutput> = {}
2322
- ): Array<ChangeMessage<TOutput>> {
2323
- return currentStateAsChanges(this, options)
2324
- }
2325
-
2326
- /**
2327
- * Subscribe to changes in the collection
2328
- * @param callback - Function called when items change
2329
- * @param options - Subscription options including includeInitialState and where filter
2330
- * @returns Unsubscribe function - Call this to stop listening for changes
2331
- * @example
2332
- * // Basic subscription
2333
- * const unsubscribe = collection.subscribeChanges((changes) => {
2334
- * changes.forEach(change => {
2335
- * console.log(`${change.type}: ${change.key}`, change.value)
2336
- * })
2337
- * })
2338
- *
2339
- * // Later: unsubscribe()
2340
- *
2341
- * @example
2342
- * // Include current state immediately
2343
- * const unsubscribe = collection.subscribeChanges((changes) => {
2344
- * updateUI(changes)
2345
- * }, { includeInitialState: true })
2346
- *
2347
- * @example
2348
- * // Subscribe only to changes matching a condition
2349
- * const unsubscribe = collection.subscribeChanges((changes) => {
2350
- * updateUI(changes)
2351
- * }, {
2352
- * includeInitialState: true,
2353
- * where: (row) => row.status === 'active'
2354
- * })
2355
- *
2356
- * @example
2357
- * // Subscribe using a pre-compiled expression
2358
- * const unsubscribe = collection.subscribeChanges((changes) => {
2359
- * updateUI(changes)
2360
- * }, {
2361
- * includeInitialState: true,
2362
- * whereExpression: eq(row.status, 'active')
2363
- * })
2364
- */
2365
- public subscribeChanges(
2366
- callback: (changes: Array<ChangeMessage<TOutput>>) => void,
2367
- options: SubscribeChangesOptions<TOutput> = {}
2368
- ): () => void {
2369
- // Start sync and track subscriber
2370
- this.addSubscriber()
2371
-
2372
- // Auto-index for where expressions if enabled
2373
- if (options.whereExpression) {
2374
- ensureIndexForExpression(options.whereExpression, this)
2375
- }
2376
-
2377
- // Create a filtered callback if where clause is provided
2378
- const filteredCallback =
2379
- options.where || options.whereExpression
2380
- ? createFilteredCallback(callback, options)
2381
- : callback
2382
-
2383
- if (options.includeInitialState) {
2384
- // First send the current state as changes (filtered if needed)
2385
- const initialChanges = this.currentStateAsChanges({
2386
- where: options.where,
2387
- whereExpression: options.whereExpression,
2388
- })
2389
- filteredCallback(initialChanges)
2390
- }
2391
-
2392
- // Add to batched listeners
2393
- this.changeListeners.add(filteredCallback)
2394
-
2395
- return () => {
2396
- this.changeListeners.delete(filteredCallback)
2397
- this.removeSubscriber()
2398
- }
2399
- }
2400
-
2401
- /**
2402
- * Subscribe to changes for a specific key
2403
- */
2404
- public subscribeChangesKey(
2405
- key: TKey,
2406
- listener: ChangeListener<TOutput, TKey>,
2407
- { includeInitialState = false }: { includeInitialState?: boolean } = {}
2408
- ): () => void {
2409
- // Start sync and track subscriber
2410
- this.addSubscriber()
2411
-
2412
- if (!this.changeKeyListeners.has(key)) {
2413
- this.changeKeyListeners.set(key, new Set())
2414
- }
2415
-
2416
- if (includeInitialState) {
2417
- // First send the current state as changes
2418
- listener([
2419
- {
2420
- type: `insert`,
2421
- key,
2422
- value: this.get(key)!,
2423
- },
2424
- ])
2425
- }
2426
-
2427
- this.changeKeyListeners.get(key)!.add(listener)
2428
-
2429
- return () => {
2430
- const listeners = this.changeKeyListeners.get(key)
2431
- if (listeners) {
2432
- listeners.delete(listener)
2433
- if (listeners.size === 0) {
2434
- this.changeKeyListeners.delete(key)
2435
- }
2436
- }
2437
- this.removeSubscriber()
2438
- }
2439
- }
2440
-
2441
- /**
2442
- * Capture visible state for keys that will be affected by pending sync operations
2443
- * This must be called BEFORE onTransactionStateChange clears optimistic state
2444
- */
2445
- private capturePreSyncVisibleState(): void {
2446
- if (this.pendingSyncedTransactions.length === 0) return
2447
-
2448
- // Clear any previous capture
2449
- this.preSyncVisibleState.clear()
2450
-
2451
- // Get all keys that will be affected by sync operations
2452
- const syncedKeys = new Set<TKey>()
2453
- for (const transaction of this.pendingSyncedTransactions) {
2454
- for (const operation of transaction.operations) {
2455
- syncedKeys.add(operation.key as TKey)
2456
- }
2457
- }
2458
-
2459
- // Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
2460
- for (const key of syncedKeys) {
2461
- this.recentlySyncedKeys.add(key)
2462
- }
2463
-
2464
- // Only capture current visible state for keys that will be affected by sync operations
2465
- // This is much more efficient than capturing the entire collection state
2466
- for (const key of syncedKeys) {
2467
- const currentValue = this.get(key)
2468
- if (currentValue !== undefined) {
2469
- this.preSyncVisibleState.set(key, currentValue)
2470
- }
2471
- }
2472
- }
2473
-
2474
- /**
2475
- * Trigger a recomputation when transactions change
2476
- * This method should be called by the Transaction class when state changes
2477
- */
2478
- public onTransactionStateChange(): void {
2479
- // Check if commitPendingTransactions will be called after this
2480
- // by checking if there are pending sync transactions (same logic as in transactions.ts)
2481
- this.shouldBatchEvents = this.pendingSyncedTransactions.length > 0
2482
-
2483
- // CRITICAL: Capture visible state BEFORE clearing optimistic state
2484
- this.capturePreSyncVisibleState()
2485
-
2486
- this.recomputeOptimisticState(false)
2487
- }
2488
- }