@tanstack/db 0.3.2 → 0.4.1

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