@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
@@ -0,0 +1,866 @@
1
+ import { deepEquals } from "../utils"
2
+ import { SortedMap } from "../SortedMap"
3
+ import type { Transaction } from "../transactions"
4
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
5
+ import type {
6
+ ChangeMessage,
7
+ CollectionConfig,
8
+ OptimisticChangeMessage,
9
+ } from "../types"
10
+ import type { CollectionImpl } from "./index.js"
11
+ import type { CollectionLifecycleManager } from "./lifecycle"
12
+ import type { CollectionChangesManager } from "./changes"
13
+ import type { CollectionIndexesManager } from "./indexes"
14
+
15
+ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
16
+ committed: boolean
17
+ operations: Array<OptimisticChangeMessage<T>>
18
+ truncate?: boolean
19
+ deletedKeys: Set<string | number>
20
+ }
21
+
22
+ export class CollectionStateManager<
23
+ TOutput extends object = Record<string, unknown>,
24
+ TKey extends string | number = string | number,
25
+ TSchema extends StandardSchemaV1 = StandardSchemaV1,
26
+ TInput extends object = TOutput,
27
+ > {
28
+ public config!: CollectionConfig<TOutput, TKey, TSchema>
29
+ public collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
30
+ public lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
31
+ public changes!: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
32
+ public indexes!: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
33
+
34
+ // Core state - make public for testing
35
+ public transactions: SortedMap<string, Transaction<any>>
36
+ public pendingSyncedTransactions: Array<PendingSyncedTransaction<TOutput>> =
37
+ []
38
+ public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
39
+ public syncedMetadata = new Map<TKey, unknown>()
40
+
41
+ // Optimistic state tracking - make public for testing
42
+ public optimisticUpserts = new Map<TKey, TOutput>()
43
+ public optimisticDeletes = new Set<TKey>()
44
+
45
+ // Cached size for performance
46
+ public size = 0
47
+
48
+ // State used for computing the change events
49
+ public syncedKeys = new Set<TKey>()
50
+ public preSyncVisibleState = new Map<TKey, TOutput>()
51
+ public recentlySyncedKeys = new Set<TKey>()
52
+ public hasReceivedFirstCommit = false
53
+ public isCommittingSyncTransactions = false
54
+
55
+ /**
56
+ * Creates a new CollectionState manager
57
+ */
58
+ constructor(config: CollectionConfig<TOutput, TKey, TSchema>) {
59
+ this.config = config
60
+ this.transactions = new SortedMap<string, Transaction<any>>((a, b) =>
61
+ a.compareCreatedAt(b)
62
+ )
63
+
64
+ // Set up data storage with optional comparison function
65
+ if (config.compare) {
66
+ this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
67
+ } else {
68
+ this.syncedData = new Map<TKey, TOutput>()
69
+ }
70
+ }
71
+
72
+ setDeps(deps: {
73
+ collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
74
+ lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
75
+ changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
76
+ indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
77
+ }) {
78
+ this.collection = deps.collection
79
+ this.lifecycle = deps.lifecycle
80
+ this.changes = deps.changes
81
+ this.indexes = deps.indexes
82
+ }
83
+
84
+ /**
85
+ * Get the current value for a key (virtual derived state)
86
+ */
87
+ public get(key: TKey): TOutput | undefined {
88
+ const { optimisticDeletes, optimisticUpserts, syncedData } = this
89
+ // Check if optimistically deleted
90
+ if (optimisticDeletes.has(key)) {
91
+ return undefined
92
+ }
93
+
94
+ // Check optimistic upserts first
95
+ if (optimisticUpserts.has(key)) {
96
+ return optimisticUpserts.get(key)
97
+ }
98
+
99
+ // Fall back to synced data
100
+ return syncedData.get(key)
101
+ }
102
+
103
+ /**
104
+ * Check if a key exists in the collection (virtual derived state)
105
+ */
106
+ public has(key: TKey): boolean {
107
+ const { optimisticDeletes, optimisticUpserts, syncedData } = this
108
+ // Check if optimistically deleted
109
+ if (optimisticDeletes.has(key)) {
110
+ return false
111
+ }
112
+
113
+ // Check optimistic upserts first
114
+ if (optimisticUpserts.has(key)) {
115
+ return true
116
+ }
117
+
118
+ // Fall back to synced data
119
+ return syncedData.has(key)
120
+ }
121
+
122
+ /**
123
+ * Get all keys (virtual derived state)
124
+ */
125
+ public *keys(): IterableIterator<TKey> {
126
+ const { syncedData, optimisticDeletes, optimisticUpserts } = this
127
+ // Yield keys from synced data, skipping any that are deleted.
128
+ for (const key of syncedData.keys()) {
129
+ if (!optimisticDeletes.has(key)) {
130
+ yield key
131
+ }
132
+ }
133
+ // Yield keys from upserts that were not already in synced data.
134
+ for (const key of optimisticUpserts.keys()) {
135
+ if (!syncedData.has(key) && !optimisticDeletes.has(key)) {
136
+ // The optimisticDeletes check is technically redundant if inserts/updates always remove from deletes,
137
+ // but it's safer to keep it.
138
+ yield key
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get all values (virtual derived state)
145
+ */
146
+ public *values(): IterableIterator<TOutput> {
147
+ for (const key of this.keys()) {
148
+ const value = this.get(key)
149
+ if (value !== undefined) {
150
+ yield value
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get all entries (virtual derived state)
157
+ */
158
+ public *entries(): IterableIterator<[TKey, TOutput]> {
159
+ for (const key of this.keys()) {
160
+ const value = this.get(key)
161
+ if (value !== undefined) {
162
+ yield [key, value]
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Get all entries (virtual derived state)
169
+ */
170
+ public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> {
171
+ for (const [key, value] of this.entries()) {
172
+ yield [key, value]
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Execute a callback for each entry in the collection
178
+ */
179
+ public forEach(
180
+ callbackfn: (value: TOutput, key: TKey, index: number) => void
181
+ ): void {
182
+ let index = 0
183
+ for (const [key, value] of this.entries()) {
184
+ callbackfn(value, key, index++)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Create a new array with the results of calling a function for each entry in the collection
190
+ */
191
+ public map<U>(
192
+ callbackfn: (value: TOutput, key: TKey, index: number) => U
193
+ ): Array<U> {
194
+ const result: Array<U> = []
195
+ let index = 0
196
+ for (const [key, value] of this.entries()) {
197
+ result.push(callbackfn(value, key, index++))
198
+ }
199
+ return result
200
+ }
201
+
202
+ /**
203
+ * Check if the given collection is this collection
204
+ * @param collection The collection to check
205
+ * @returns True if the given collection is this collection, false otherwise
206
+ */
207
+ private isThisCollection(
208
+ collection: CollectionImpl<any, any, any, any, any>
209
+ ): boolean {
210
+ return collection === this.collection
211
+ }
212
+
213
+ /**
214
+ * Recompute optimistic state from active transactions
215
+ */
216
+ public recomputeOptimisticState(
217
+ triggeredByUserAction: boolean = false
218
+ ): void {
219
+ // Skip redundant recalculations when we're in the middle of committing sync transactions
220
+ if (this.isCommittingSyncTransactions) {
221
+ return
222
+ }
223
+
224
+ const previousState = new Map(this.optimisticUpserts)
225
+ const previousDeletes = new Set(this.optimisticDeletes)
226
+
227
+ // Clear current optimistic state
228
+ this.optimisticUpserts.clear()
229
+ this.optimisticDeletes.clear()
230
+
231
+ const activeTransactions: Array<Transaction<any>> = []
232
+
233
+ for (const transaction of this.transactions.values()) {
234
+ if (![`completed`, `failed`].includes(transaction.state)) {
235
+ activeTransactions.push(transaction)
236
+ }
237
+ }
238
+
239
+ // Apply active transactions only (completed transactions are handled by sync operations)
240
+ for (const transaction of activeTransactions) {
241
+ for (const mutation of transaction.mutations) {
242
+ if (this.isThisCollection(mutation.collection) && mutation.optimistic) {
243
+ switch (mutation.type) {
244
+ case `insert`:
245
+ case `update`:
246
+ this.optimisticUpserts.set(
247
+ mutation.key,
248
+ mutation.modified as TOutput
249
+ )
250
+ this.optimisticDeletes.delete(mutation.key)
251
+ break
252
+ case `delete`:
253
+ this.optimisticUpserts.delete(mutation.key)
254
+ this.optimisticDeletes.add(mutation.key)
255
+ break
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ // Update cached size
262
+ this.size = this.calculateSize()
263
+
264
+ // Collect events for changes
265
+ const events: Array<ChangeMessage<TOutput, TKey>> = []
266
+ this.collectOptimisticChanges(previousState, previousDeletes, events)
267
+
268
+ // Filter out events for recently synced keys to prevent duplicates
269
+ // BUT: Only filter out events that are actually from sync operations
270
+ // New user transactions should NOT be filtered even if the key was recently synced
271
+ const filteredEventsBySyncStatus = events.filter((event) => {
272
+ if (!this.recentlySyncedKeys.has(event.key)) {
273
+ return true // Key not recently synced, allow event through
274
+ }
275
+
276
+ // Key was recently synced - allow if this is a user-triggered action
277
+ if (triggeredByUserAction) {
278
+ return true
279
+ }
280
+
281
+ // Otherwise filter out duplicate sync events
282
+ return false
283
+ })
284
+
285
+ // Filter out redundant delete events if there are pending sync transactions
286
+ // that will immediately restore the same data, but only for completed transactions
287
+ // IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking
288
+ if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) {
289
+ const pendingSyncKeys = new Set<TKey>()
290
+
291
+ // Collect keys from pending sync operations
292
+ for (const transaction of this.pendingSyncedTransactions) {
293
+ for (const operation of transaction.operations) {
294
+ pendingSyncKeys.add(operation.key as TKey)
295
+ }
296
+ }
297
+
298
+ // Only filter out delete events for keys that:
299
+ // 1. Have pending sync operations AND
300
+ // 2. Are from completed transactions (being cleaned up)
301
+ const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
302
+ if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
303
+ // Check if this delete is from clearing optimistic state of completed transactions
304
+ // We can infer this by checking if we have no remaining optimistic mutations for this key
305
+ const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
306
+ tx.mutations.some(
307
+ (m) => this.isThisCollection(m.collection) && m.key === event.key
308
+ )
309
+ )
310
+
311
+ if (!hasActiveOptimisticMutation) {
312
+ return false // Skip this delete event as sync will restore the data
313
+ }
314
+ }
315
+ return true
316
+ })
317
+
318
+ // Update indexes for the filtered events
319
+ if (filteredEvents.length > 0) {
320
+ this.indexes.updateIndexes(filteredEvents)
321
+ }
322
+ this.changes.emitEvents(filteredEvents, triggeredByUserAction)
323
+ } else {
324
+ // Update indexes for all events
325
+ if (filteredEventsBySyncStatus.length > 0) {
326
+ this.indexes.updateIndexes(filteredEventsBySyncStatus)
327
+ }
328
+ // Emit all events if no pending sync transactions
329
+ this.changes.emitEvents(filteredEventsBySyncStatus, triggeredByUserAction)
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Calculate the current size based on synced data and optimistic changes
335
+ */
336
+ private calculateSize(): number {
337
+ const syncedSize = this.syncedData.size
338
+ const deletesFromSynced = Array.from(this.optimisticDeletes).filter(
339
+ (key) => this.syncedData.has(key) && !this.optimisticUpserts.has(key)
340
+ ).length
341
+ const upsertsNotInSynced = Array.from(this.optimisticUpserts.keys()).filter(
342
+ (key) => !this.syncedData.has(key)
343
+ ).length
344
+
345
+ return syncedSize - deletesFromSynced + upsertsNotInSynced
346
+ }
347
+
348
+ /**
349
+ * Collect events for optimistic changes
350
+ */
351
+ private collectOptimisticChanges(
352
+ previousUpserts: Map<TKey, TOutput>,
353
+ previousDeletes: Set<TKey>,
354
+ events: Array<ChangeMessage<TOutput, TKey>>
355
+ ): void {
356
+ const allKeys = new Set([
357
+ ...previousUpserts.keys(),
358
+ ...this.optimisticUpserts.keys(),
359
+ ...previousDeletes,
360
+ ...this.optimisticDeletes,
361
+ ])
362
+
363
+ for (const key of allKeys) {
364
+ const currentValue = this.get(key)
365
+ const previousValue = this.getPreviousValue(
366
+ key,
367
+ previousUpserts,
368
+ previousDeletes
369
+ )
370
+
371
+ if (previousValue !== undefined && currentValue === undefined) {
372
+ events.push({ type: `delete`, key, value: previousValue })
373
+ } else if (previousValue === undefined && currentValue !== undefined) {
374
+ events.push({ type: `insert`, key, value: currentValue })
375
+ } else if (
376
+ previousValue !== undefined &&
377
+ currentValue !== undefined &&
378
+ previousValue !== currentValue
379
+ ) {
380
+ events.push({
381
+ type: `update`,
382
+ key,
383
+ value: currentValue,
384
+ previousValue,
385
+ })
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Get the previous value for a key given previous optimistic state
392
+ */
393
+ private getPreviousValue(
394
+ key: TKey,
395
+ previousUpserts: Map<TKey, TOutput>,
396
+ previousDeletes: Set<TKey>
397
+ ): TOutput | undefined {
398
+ if (previousDeletes.has(key)) {
399
+ return undefined
400
+ }
401
+ if (previousUpserts.has(key)) {
402
+ return previousUpserts.get(key)
403
+ }
404
+ return this.syncedData.get(key)
405
+ }
406
+
407
+ /**
408
+ * Attempts to commit pending synced transactions if there are no active transactions
409
+ * This method processes operations from pending transactions and applies them to the synced data
410
+ */
411
+ commitPendingTransactions = () => {
412
+ // Check if there are any persisting transaction
413
+ let hasPersistingTransaction = false
414
+ for (const transaction of this.transactions.values()) {
415
+ if (transaction.state === `persisting`) {
416
+ hasPersistingTransaction = true
417
+ break
418
+ }
419
+ }
420
+
421
+ // pending synced transactions could be either `committed` or still open.
422
+ // we only want to process `committed` transactions here
423
+ const {
424
+ committedSyncedTransactions,
425
+ uncommittedSyncedTransactions,
426
+ hasTruncateSync,
427
+ } = this.pendingSyncedTransactions.reduce(
428
+ (acc, t) => {
429
+ if (t.committed) {
430
+ acc.committedSyncedTransactions.push(t)
431
+ if (t.truncate === true) {
432
+ acc.hasTruncateSync = true
433
+ }
434
+ } else {
435
+ acc.uncommittedSyncedTransactions.push(t)
436
+ }
437
+ return acc
438
+ },
439
+ {
440
+ committedSyncedTransactions: [] as Array<
441
+ PendingSyncedTransaction<TOutput>
442
+ >,
443
+ uncommittedSyncedTransactions: [] as Array<
444
+ PendingSyncedTransaction<TOutput>
445
+ >,
446
+ hasTruncateSync: false,
447
+ }
448
+ )
449
+
450
+ if (!hasPersistingTransaction || hasTruncateSync) {
451
+ // Set flag to prevent redundant optimistic state recalculations
452
+ this.isCommittingSyncTransactions = true
453
+
454
+ // First collect all keys that will be affected by sync operations
455
+ const changedKeys = new Set<TKey>()
456
+ for (const transaction of committedSyncedTransactions) {
457
+ for (const operation of transaction.operations) {
458
+ changedKeys.add(operation.key as TKey)
459
+ }
460
+ }
461
+
462
+ // Use pre-captured state if available (from optimistic scenarios),
463
+ // otherwise capture current state (for pure sync scenarios)
464
+ let currentVisibleState = this.preSyncVisibleState
465
+ if (currentVisibleState.size === 0) {
466
+ // No pre-captured state, capture it now for pure sync operations
467
+ currentVisibleState = new Map<TKey, TOutput>()
468
+ for (const key of changedKeys) {
469
+ const currentValue = this.get(key)
470
+ if (currentValue !== undefined) {
471
+ currentVisibleState.set(key, currentValue)
472
+ }
473
+ }
474
+ }
475
+
476
+ const events: Array<ChangeMessage<TOutput, TKey>> = []
477
+ const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
478
+
479
+ for (const transaction of committedSyncedTransactions) {
480
+ // Handle truncate operations first
481
+ if (transaction.truncate) {
482
+ // TRUNCATE PHASE
483
+ // 1) Emit a delete for every currently-synced key so downstream listeners/indexes
484
+ // observe a clear-before-rebuild. We intentionally skip keys already in
485
+ // optimisticDeletes because their delete was previously emitted by the user.
486
+ for (const key of this.syncedData.keys()) {
487
+ if (this.optimisticDeletes.has(key)) continue
488
+ const previousValue =
489
+ this.optimisticUpserts.get(key) || this.syncedData.get(key)
490
+ if (previousValue !== undefined) {
491
+ events.push({ type: `delete`, key, value: previousValue })
492
+ }
493
+ }
494
+
495
+ // 2) Clear the authoritative synced base. Subsequent server ops in this
496
+ // same commit will rebuild the base atomically.
497
+ this.syncedData.clear()
498
+ this.syncedMetadata.clear()
499
+ this.syncedKeys.clear()
500
+
501
+ // 3) Clear currentVisibleState for truncated keys to ensure subsequent operations
502
+ // are compared against the post-truncate state (undefined) rather than pre-truncate state
503
+ // This ensures that re-inserted keys are emitted as INSERT events, not UPDATE events
504
+ for (const key of changedKeys) {
505
+ currentVisibleState.delete(key)
506
+ }
507
+ }
508
+
509
+ for (const operation of transaction.operations) {
510
+ const key = operation.key as TKey
511
+ this.syncedKeys.add(key)
512
+
513
+ // Update metadata
514
+ switch (operation.type) {
515
+ case `insert`:
516
+ this.syncedMetadata.set(key, operation.metadata)
517
+ break
518
+ case `update`:
519
+ this.syncedMetadata.set(
520
+ key,
521
+ Object.assign(
522
+ {},
523
+ this.syncedMetadata.get(key),
524
+ operation.metadata
525
+ )
526
+ )
527
+ break
528
+ case `delete`:
529
+ this.syncedMetadata.delete(key)
530
+ break
531
+ }
532
+
533
+ // Update synced data
534
+ switch (operation.type) {
535
+ case `insert`:
536
+ this.syncedData.set(key, operation.value)
537
+ break
538
+ case `update`: {
539
+ if (rowUpdateMode === `partial`) {
540
+ const updatedValue = Object.assign(
541
+ {},
542
+ this.syncedData.get(key),
543
+ operation.value
544
+ )
545
+ this.syncedData.set(key, updatedValue)
546
+ } else {
547
+ this.syncedData.set(key, operation.value)
548
+ }
549
+ break
550
+ }
551
+ case `delete`:
552
+ this.syncedData.delete(key)
553
+ break
554
+ }
555
+ }
556
+ }
557
+
558
+ // After applying synced operations, if this commit included a truncate,
559
+ // re-apply optimistic mutations on top of the fresh synced base. This ensures
560
+ // the UI preserves local intent while respecting server rebuild semantics.
561
+ // Ordering: deletes (above) -> server ops (just applied) -> optimistic upserts.
562
+ if (hasTruncateSync) {
563
+ // Avoid duplicating keys that were inserted/updated by synced operations in this commit
564
+ const syncedInsertedOrUpdatedKeys = new Set<TKey>()
565
+ for (const t of committedSyncedTransactions) {
566
+ for (const op of t.operations) {
567
+ if (op.type === `insert` || op.type === `update`) {
568
+ syncedInsertedOrUpdatedKeys.add(op.key as TKey)
569
+ }
570
+ }
571
+ }
572
+
573
+ // Build re-apply sets from ACTIVE optimistic transactions against the new synced base
574
+ // We do not copy maps; we compute intent directly from transactions to avoid drift.
575
+ const reapplyUpserts = new Map<TKey, TOutput>()
576
+ const reapplyDeletes = new Set<TKey>()
577
+
578
+ for (const tx of this.transactions.values()) {
579
+ if ([`completed`, `failed`].includes(tx.state)) continue
580
+ for (const mutation of tx.mutations) {
581
+ if (
582
+ !this.isThisCollection(mutation.collection) ||
583
+ !mutation.optimistic
584
+ )
585
+ continue
586
+ const key = mutation.key as TKey
587
+ switch (mutation.type) {
588
+ case `insert`:
589
+ reapplyUpserts.set(key, mutation.modified as TOutput)
590
+ reapplyDeletes.delete(key)
591
+ break
592
+ case `update`: {
593
+ const base = this.syncedData.get(key)
594
+ const next = base
595
+ ? (Object.assign({}, base, mutation.changes) as TOutput)
596
+ : (mutation.modified as TOutput)
597
+ reapplyUpserts.set(key, next)
598
+ reapplyDeletes.delete(key)
599
+ break
600
+ }
601
+ case `delete`:
602
+ reapplyUpserts.delete(key)
603
+ reapplyDeletes.add(key)
604
+ break
605
+ }
606
+ }
607
+ }
608
+
609
+ // Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
610
+ // If the server also inserted/updated the same key in this batch, override that value
611
+ // with the optimistic value to preserve local intent.
612
+ for (const [key, value] of reapplyUpserts) {
613
+ if (reapplyDeletes.has(key)) continue
614
+ if (syncedInsertedOrUpdatedKeys.has(key)) {
615
+ let foundInsert = false
616
+ for (let i = events.length - 1; i >= 0; i--) {
617
+ const evt = events[i]!
618
+ if (evt.key === key && evt.type === `insert`) {
619
+ evt.value = value
620
+ foundInsert = true
621
+ break
622
+ }
623
+ }
624
+ if (!foundInsert) {
625
+ events.push({ type: `insert`, key, value })
626
+ }
627
+ } else {
628
+ events.push({ type: `insert`, key, value })
629
+ }
630
+ }
631
+
632
+ // Finally, ensure we do NOT insert keys that have an outstanding optimistic delete.
633
+ if (events.length > 0 && reapplyDeletes.size > 0) {
634
+ const filtered: Array<ChangeMessage<TOutput, TKey>> = []
635
+ for (const evt of events) {
636
+ if (evt.type === `insert` && reapplyDeletes.has(evt.key)) {
637
+ continue
638
+ }
639
+ filtered.push(evt)
640
+ }
641
+ events.length = 0
642
+ events.push(...filtered)
643
+ }
644
+
645
+ // Ensure listeners are active before emitting this critical batch
646
+ if (this.lifecycle.status !== `ready`) {
647
+ this.lifecycle.setStatus(`ready`)
648
+ }
649
+ }
650
+
651
+ // Maintain optimistic state appropriately
652
+ // Clear optimistic state since sync operations will now provide the authoritative data.
653
+ // Any still-active user transactions will be re-applied below in recompute.
654
+ this.optimisticUpserts.clear()
655
+ this.optimisticDeletes.clear()
656
+
657
+ // Reset flag and recompute optimistic state for any remaining active transactions
658
+ this.isCommittingSyncTransactions = false
659
+ for (const transaction of this.transactions.values()) {
660
+ if (![`completed`, `failed`].includes(transaction.state)) {
661
+ for (const mutation of transaction.mutations) {
662
+ if (
663
+ this.isThisCollection(mutation.collection) &&
664
+ mutation.optimistic
665
+ ) {
666
+ switch (mutation.type) {
667
+ case `insert`:
668
+ case `update`:
669
+ this.optimisticUpserts.set(
670
+ mutation.key,
671
+ mutation.modified as TOutput
672
+ )
673
+ this.optimisticDeletes.delete(mutation.key)
674
+ break
675
+ case `delete`:
676
+ this.optimisticUpserts.delete(mutation.key)
677
+ this.optimisticDeletes.add(mutation.key)
678
+ break
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
684
+
685
+ // Check for redundant sync operations that match completed optimistic operations
686
+ const completedOptimisticOps = new Map<TKey, any>()
687
+
688
+ for (const transaction of this.transactions.values()) {
689
+ if (transaction.state === `completed`) {
690
+ for (const mutation of transaction.mutations) {
691
+ if (
692
+ this.isThisCollection(mutation.collection) &&
693
+ changedKeys.has(mutation.key)
694
+ ) {
695
+ completedOptimisticOps.set(mutation.key, {
696
+ type: mutation.type,
697
+ value: mutation.modified,
698
+ })
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ // Now check what actually changed in the final visible state
705
+ for (const key of changedKeys) {
706
+ const previousVisibleValue = currentVisibleState.get(key)
707
+ const newVisibleValue = this.get(key) // This returns the new derived state
708
+
709
+ // Check if this sync operation is redundant with a completed optimistic operation
710
+ const completedOp = completedOptimisticOps.get(key)
711
+ const isRedundantSync =
712
+ completedOp &&
713
+ newVisibleValue !== undefined &&
714
+ deepEquals(completedOp.value, newVisibleValue)
715
+
716
+ if (!isRedundantSync) {
717
+ if (
718
+ previousVisibleValue === undefined &&
719
+ newVisibleValue !== undefined
720
+ ) {
721
+ events.push({
722
+ type: `insert`,
723
+ key,
724
+ value: newVisibleValue,
725
+ })
726
+ } else if (
727
+ previousVisibleValue !== undefined &&
728
+ newVisibleValue === undefined
729
+ ) {
730
+ events.push({
731
+ type: `delete`,
732
+ key,
733
+ value: previousVisibleValue,
734
+ })
735
+ } else if (
736
+ previousVisibleValue !== undefined &&
737
+ newVisibleValue !== undefined &&
738
+ !deepEquals(previousVisibleValue, newVisibleValue)
739
+ ) {
740
+ events.push({
741
+ type: `update`,
742
+ key,
743
+ value: newVisibleValue,
744
+ previousValue: previousVisibleValue,
745
+ })
746
+ }
747
+ }
748
+ }
749
+
750
+ // Update cached size after synced data changes
751
+ this.size = this.calculateSize()
752
+
753
+ // Update indexes for all events before emitting
754
+ if (events.length > 0) {
755
+ this.indexes.updateIndexes(events)
756
+ }
757
+
758
+ // End batching and emit all events (combines any batched events with sync events)
759
+ this.changes.emitEvents(events, true)
760
+
761
+ this.pendingSyncedTransactions = uncommittedSyncedTransactions
762
+
763
+ // Clear the pre-sync state since sync operations are complete
764
+ this.preSyncVisibleState.clear()
765
+
766
+ // Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
767
+ Promise.resolve().then(() => {
768
+ this.recentlySyncedKeys.clear()
769
+ })
770
+
771
+ // Call any registered one-time commit listeners
772
+ if (!this.hasReceivedFirstCommit) {
773
+ this.hasReceivedFirstCommit = true
774
+ const callbacks = [...this.lifecycle.onFirstReadyCallbacks]
775
+ this.lifecycle.onFirstReadyCallbacks = []
776
+ callbacks.forEach((callback) => callback())
777
+ }
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Schedule cleanup of a transaction when it completes
783
+ */
784
+ public scheduleTransactionCleanup(transaction: Transaction<any>): void {
785
+ // Only schedule cleanup for transactions that aren't already completed
786
+ if (transaction.state === `completed`) {
787
+ this.transactions.delete(transaction.id)
788
+ return
789
+ }
790
+
791
+ // Schedule cleanup when the transaction completes
792
+ transaction.isPersisted.promise
793
+ .then(() => {
794
+ // Transaction completed successfully, remove it immediately
795
+ this.transactions.delete(transaction.id)
796
+ })
797
+ .catch(() => {
798
+ // Transaction failed, but we want to keep failed transactions for reference
799
+ // so don't remove it.
800
+ // This empty catch block is necessary to prevent unhandled promise rejections.
801
+ })
802
+ }
803
+
804
+ /**
805
+ * Capture visible state for keys that will be affected by pending sync operations
806
+ * This must be called BEFORE onTransactionStateChange clears optimistic state
807
+ */
808
+ public capturePreSyncVisibleState(): void {
809
+ if (this.pendingSyncedTransactions.length === 0) return
810
+
811
+ // Clear any previous capture
812
+ this.preSyncVisibleState.clear()
813
+
814
+ // Get all keys that will be affected by sync operations
815
+ const syncedKeys = new Set<TKey>()
816
+ for (const transaction of this.pendingSyncedTransactions) {
817
+ for (const operation of transaction.operations) {
818
+ syncedKeys.add(operation.key as TKey)
819
+ }
820
+ }
821
+
822
+ // Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
823
+ for (const key of syncedKeys) {
824
+ this.recentlySyncedKeys.add(key)
825
+ }
826
+
827
+ // Only capture current visible state for keys that will be affected by sync operations
828
+ // This is much more efficient than capturing the entire collection state
829
+ for (const key of syncedKeys) {
830
+ const currentValue = this.get(key)
831
+ if (currentValue !== undefined) {
832
+ this.preSyncVisibleState.set(key, currentValue)
833
+ }
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Trigger a recomputation when transactions change
839
+ * This method should be called by the Transaction class when state changes
840
+ */
841
+ public onTransactionStateChange(): void {
842
+ // Check if commitPendingTransactions will be called after this
843
+ // by checking if there are pending sync transactions (same logic as in transactions.ts)
844
+ this.changes.shouldBatchEvents = this.pendingSyncedTransactions.length > 0
845
+
846
+ // CRITICAL: Capture visible state BEFORE clearing optimistic state
847
+ this.capturePreSyncVisibleState()
848
+
849
+ this.recomputeOptimisticState(false)
850
+ }
851
+
852
+ /**
853
+ * Clean up the collection by stopping sync and clearing data
854
+ * This can be called manually or automatically by garbage collection
855
+ */
856
+ public cleanup(): void {
857
+ this.syncedData.clear()
858
+ this.syncedMetadata.clear()
859
+ this.optimisticUpserts.clear()
860
+ this.optimisticDeletes.clear()
861
+ this.size = 0
862
+ this.pendingSyncedTransactions = []
863
+ this.syncedKeys.clear()
864
+ this.hasReceivedFirstCommit = false
865
+ }
866
+ }