@tanstack/db 0.0.12 → 0.0.14
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.
- package/dist/cjs/SortedMap.cjs +38 -11
- package/dist/cjs/SortedMap.cjs.map +1 -1
- package/dist/cjs/SortedMap.d.cts +10 -0
- package/dist/cjs/collection.cjs +467 -95
- package/dist/cjs/collection.cjs.map +1 -1
- package/dist/cjs/collection.d.cts +81 -5
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/optimistic-action.cjs +21 -0
- package/dist/cjs/optimistic-action.cjs.map +1 -0
- package/dist/cjs/optimistic-action.d.cts +39 -0
- package/dist/cjs/query/compiled-query.cjs +21 -11
- package/dist/cjs/query/compiled-query.cjs.map +1 -1
- package/dist/cjs/query/query-builder.cjs +2 -2
- package/dist/cjs/query/query-builder.cjs.map +1 -1
- package/dist/cjs/transactions.cjs +3 -1
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/transactions.d.cts +4 -4
- package/dist/cjs/types.d.cts +45 -1
- package/dist/esm/SortedMap.d.ts +10 -0
- package/dist/esm/SortedMap.js +38 -11
- package/dist/esm/SortedMap.js.map +1 -1
- package/dist/esm/collection.d.ts +81 -5
- package/dist/esm/collection.js +467 -95
- package/dist/esm/collection.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/optimistic-action.d.ts +39 -0
- package/dist/esm/optimistic-action.js +21 -0
- package/dist/esm/optimistic-action.js.map +1 -0
- package/dist/esm/query/compiled-query.js +21 -11
- package/dist/esm/query/compiled-query.js.map +1 -1
- package/dist/esm/query/query-builder.js +2 -2
- package/dist/esm/query/query-builder.js.map +1 -1
- package/dist/esm/transactions.d.ts +4 -4
- package/dist/esm/transactions.js +3 -1
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/types.d.ts +45 -1
- package/package.json +1 -1
- package/src/SortedMap.ts +46 -13
- package/src/collection.ts +624 -119
- package/src/index.ts +1 -0
- package/src/optimistic-action.ts +65 -0
- package/src/query/compiled-query.ts +36 -14
- package/src/query/query-builder.ts +2 -2
- package/src/transactions.ts +14 -5
- package/src/types.ts +48 -1
package/src/collection.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ChangeListener,
|
|
7
7
|
ChangeMessage,
|
|
8
8
|
CollectionConfig,
|
|
9
|
+
CollectionStatus,
|
|
9
10
|
Fn,
|
|
10
11
|
InsertConfig,
|
|
11
12
|
OperationConfig,
|
|
@@ -138,10 +139,12 @@ export class CollectionImpl<
|
|
|
138
139
|
T extends object = Record<string, unknown>,
|
|
139
140
|
TKey extends string | number = string | number,
|
|
140
141
|
> {
|
|
141
|
-
public
|
|
142
|
+
public config: CollectionConfig<T, TKey, any>
|
|
142
143
|
|
|
143
144
|
// Core state - make public for testing
|
|
144
|
-
public
|
|
145
|
+
public transactions: SortedMap<string, Transaction<any>>
|
|
146
|
+
public pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
|
|
147
|
+
public syncedData: Map<TKey, T> | SortedMap<TKey, T>
|
|
145
148
|
public syncedMetadata = new Map<TKey, unknown>()
|
|
146
149
|
|
|
147
150
|
// Optimistic state tracking - make public for testing
|
|
@@ -159,14 +162,23 @@ export class CollectionImpl<
|
|
|
159
162
|
// This is populated by createCollection
|
|
160
163
|
public utils: Record<string, Fn> = {}
|
|
161
164
|
|
|
162
|
-
|
|
165
|
+
// State used for computing the change events
|
|
163
166
|
private syncedKeys = new Set<TKey>()
|
|
164
|
-
|
|
167
|
+
private preSyncVisibleState = new Map<TKey, T>()
|
|
168
|
+
private recentlySyncedKeys = new Set<TKey>()
|
|
165
169
|
private hasReceivedFirstCommit = false
|
|
170
|
+
private isCommittingSyncTransactions = false
|
|
166
171
|
|
|
167
172
|
// Array to store one-time commit listeners
|
|
168
173
|
private onFirstCommitCallbacks: Array<() => void> = []
|
|
169
174
|
|
|
175
|
+
// Lifecycle management
|
|
176
|
+
private _status: CollectionStatus = `idle`
|
|
177
|
+
private activeSubscribersCount = 0
|
|
178
|
+
private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
179
|
+
private preloadPromise: Promise<void> | null = null
|
|
180
|
+
private syncCleanupFn: (() => void) | null = null
|
|
181
|
+
|
|
170
182
|
/**
|
|
171
183
|
* Register a callback to be executed on the next commit
|
|
172
184
|
* Useful for preloading collections
|
|
@@ -178,6 +190,71 @@ export class CollectionImpl<
|
|
|
178
190
|
|
|
179
191
|
public id = ``
|
|
180
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Gets the current status of the collection
|
|
195
|
+
*/
|
|
196
|
+
public get status(): CollectionStatus {
|
|
197
|
+
return this._status
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validates that the collection is in a usable state for data operations
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
private validateCollectionUsable(operation: string): void {
|
|
205
|
+
switch (this._status) {
|
|
206
|
+
case `error`:
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Cannot perform ${operation} on collection "${this.id}" - collection is in error state. ` +
|
|
209
|
+
`Try calling cleanup() and restarting the collection.`
|
|
210
|
+
)
|
|
211
|
+
case `cleaned-up`:
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. ` +
|
|
214
|
+
`The collection will automatically restart on next access.`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validates state transitions to prevent invalid status changes
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
private validateStatusTransition(
|
|
224
|
+
from: CollectionStatus,
|
|
225
|
+
to: CollectionStatus
|
|
226
|
+
): void {
|
|
227
|
+
if (from === to) {
|
|
228
|
+
// Allow same state transitions
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const validTransitions: Record<
|
|
232
|
+
CollectionStatus,
|
|
233
|
+
Array<CollectionStatus>
|
|
234
|
+
> = {
|
|
235
|
+
idle: [`loading`, `error`, `cleaned-up`],
|
|
236
|
+
loading: [`ready`, `error`, `cleaned-up`],
|
|
237
|
+
ready: [`cleaned-up`, `error`],
|
|
238
|
+
error: [`cleaned-up`, `idle`],
|
|
239
|
+
"cleaned-up": [`loading`, `error`],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!validTransitions[from].includes(to)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Safely update the collection status with validation
|
|
251
|
+
* @private
|
|
252
|
+
*/
|
|
253
|
+
private setStatus(newStatus: CollectionStatus): void {
|
|
254
|
+
this.validateStatusTransition(this._status, newStatus)
|
|
255
|
+
this._status = newStatus
|
|
256
|
+
}
|
|
257
|
+
|
|
181
258
|
/**
|
|
182
259
|
* Creates a new Collection instance
|
|
183
260
|
*
|
|
@@ -206,74 +283,276 @@ export class CollectionImpl<
|
|
|
206
283
|
|
|
207
284
|
this.config = config
|
|
208
285
|
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
collection: this,
|
|
212
|
-
begin: () => {
|
|
213
|
-
this.pendingSyncedTransactions.push({
|
|
214
|
-
committed: false,
|
|
215
|
-
operations: [],
|
|
216
|
-
})
|
|
217
|
-
},
|
|
218
|
-
write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
|
|
219
|
-
const pendingTransaction =
|
|
220
|
-
this.pendingSyncedTransactions[
|
|
221
|
-
this.pendingSyncedTransactions.length - 1
|
|
222
|
-
]
|
|
223
|
-
if (!pendingTransaction) {
|
|
224
|
-
throw new Error(`No pending sync transaction to write to`)
|
|
225
|
-
}
|
|
226
|
-
if (pendingTransaction.committed) {
|
|
227
|
-
throw new Error(
|
|
228
|
-
`The pending sync transaction is already committed, you can't still write to it.`
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
286
|
+
// Store in global collections store
|
|
287
|
+
collectionsStore.set(this.id, this)
|
|
232
288
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
289
|
+
// Set up data storage with optional comparison function
|
|
290
|
+
if (this.config.compare) {
|
|
291
|
+
this.syncedData = new SortedMap<TKey, T>(this.config.compare)
|
|
292
|
+
} else {
|
|
293
|
+
this.syncedData = new Map<TKey, T>()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Only start sync immediately if explicitly enabled
|
|
297
|
+
if (config.startSync === true) {
|
|
298
|
+
this.startSync()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Start sync immediately - internal method for compiled queries
|
|
304
|
+
* This bypasses lazy loading for special cases like live query results
|
|
305
|
+
*/
|
|
306
|
+
public startSyncImmediate(): void {
|
|
307
|
+
this.startSync()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start the sync process for this collection
|
|
312
|
+
* This is called when the collection is first accessed or preloaded
|
|
313
|
+
*/
|
|
314
|
+
private startSync(): void {
|
|
315
|
+
if (this._status !== `idle` && this._status !== `cleaned-up`) {
|
|
316
|
+
return // Already started or in progress
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.setStatus(`loading`)
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const cleanupFn = this.config.sync.sync({
|
|
323
|
+
collection: this,
|
|
324
|
+
begin: () => {
|
|
325
|
+
this.pendingSyncedTransactions.push({
|
|
326
|
+
committed: false,
|
|
327
|
+
operations: [],
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
|
|
331
|
+
const pendingTransaction =
|
|
332
|
+
this.pendingSyncedTransactions[
|
|
333
|
+
this.pendingSyncedTransactions.length - 1
|
|
334
|
+
]
|
|
335
|
+
if (!pendingTransaction) {
|
|
336
|
+
throw new Error(`No pending sync transaction to write to`)
|
|
337
|
+
}
|
|
338
|
+
if (pendingTransaction.committed) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`The pending sync transaction is already committed, you can't still write to it.`
|
|
239
341
|
)
|
|
240
|
-
|
|
342
|
+
}
|
|
343
|
+
const key = this.getKeyFromItem(messageWithoutKey.value)
|
|
344
|
+
|
|
345
|
+
// Check if an item with this key already exists when inserting
|
|
346
|
+
if (messageWithoutKey.type === `insert`) {
|
|
347
|
+
if (
|
|
348
|
+
this.syncedData.has(key) &&
|
|
349
|
+
!pendingTransaction.operations.some(
|
|
350
|
+
(op) => op.key === key && op.type === `delete`
|
|
351
|
+
)
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const message: ChangeMessage<T> = {
|
|
360
|
+
...messageWithoutKey,
|
|
361
|
+
key,
|
|
362
|
+
}
|
|
363
|
+
pendingTransaction.operations.push(message)
|
|
364
|
+
},
|
|
365
|
+
commit: () => {
|
|
366
|
+
const pendingTransaction =
|
|
367
|
+
this.pendingSyncedTransactions[
|
|
368
|
+
this.pendingSyncedTransactions.length - 1
|
|
369
|
+
]
|
|
370
|
+
if (!pendingTransaction) {
|
|
371
|
+
throw new Error(`No pending sync transaction to commit`)
|
|
372
|
+
}
|
|
373
|
+
if (pendingTransaction.committed) {
|
|
241
374
|
throw new Error(
|
|
242
|
-
`
|
|
375
|
+
`The pending sync transaction is already committed, you can't commit it again.`
|
|
243
376
|
)
|
|
244
377
|
}
|
|
245
|
-
}
|
|
246
378
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
379
|
+
pendingTransaction.committed = true
|
|
380
|
+
this.commitPendingTransactions()
|
|
381
|
+
|
|
382
|
+
// Update status to ready after first commit
|
|
383
|
+
if (this._status === `loading`) {
|
|
384
|
+
this.setStatus(`ready`)
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Store cleanup function if provided
|
|
390
|
+
this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.setStatus(`error`)
|
|
393
|
+
throw error
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Preload the collection data by starting sync if not already started
|
|
399
|
+
* Multiple concurrent calls will share the same promise
|
|
400
|
+
*/
|
|
401
|
+
public preload(): Promise<void> {
|
|
402
|
+
if (this.preloadPromise) {
|
|
403
|
+
return this.preloadPromise
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
this.preloadPromise = new Promise<void>((resolve, reject) => {
|
|
407
|
+
if (this._status === `ready`) {
|
|
408
|
+
resolve()
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (this._status === `error`) {
|
|
413
|
+
reject(new Error(`Collection is in error state`))
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Register callback BEFORE starting sync to avoid race condition
|
|
418
|
+
this.onFirstCommit(() => {
|
|
419
|
+
resolve()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// Start sync if collection hasn't started yet or was cleaned up
|
|
423
|
+
if (this._status === `idle` || this._status === `cleaned-up`) {
|
|
424
|
+
try {
|
|
425
|
+
this.startSync()
|
|
426
|
+
} catch (error) {
|
|
427
|
+
reject(error)
|
|
428
|
+
return
|
|
260
429
|
}
|
|
261
|
-
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
return this.preloadPromise
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Clean up the collection by stopping sync and clearing data
|
|
438
|
+
* This can be called manually or automatically by garbage collection
|
|
439
|
+
*/
|
|
440
|
+
public async cleanup(): Promise<void> {
|
|
441
|
+
// Clear GC timeout
|
|
442
|
+
if (this.gcTimeoutId) {
|
|
443
|
+
clearTimeout(this.gcTimeoutId)
|
|
444
|
+
this.gcTimeoutId = null
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Stop sync - wrap in try/catch since it's user-provided code
|
|
448
|
+
try {
|
|
449
|
+
if (this.syncCleanupFn) {
|
|
450
|
+
this.syncCleanupFn()
|
|
451
|
+
this.syncCleanupFn = null
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// Re-throw in a microtask to surface the error after cleanup completes
|
|
455
|
+
queueMicrotask(() => {
|
|
456
|
+
if (error instanceof Error) {
|
|
457
|
+
// Preserve the original error and stack trace
|
|
458
|
+
const wrappedError = new Error(
|
|
459
|
+
`Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
|
|
460
|
+
)
|
|
461
|
+
wrappedError.cause = error
|
|
462
|
+
wrappedError.stack = error.stack
|
|
463
|
+
throw wrappedError
|
|
464
|
+
} else {
|
|
262
465
|
throw new Error(
|
|
263
|
-
`
|
|
466
|
+
`Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
|
|
264
467
|
)
|
|
265
468
|
}
|
|
469
|
+
})
|
|
470
|
+
}
|
|
266
471
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
472
|
+
// Clear data
|
|
473
|
+
this.syncedData.clear()
|
|
474
|
+
this.syncedMetadata.clear()
|
|
475
|
+
this.derivedUpserts.clear()
|
|
476
|
+
this.derivedDeletes.clear()
|
|
477
|
+
this._size = 0
|
|
478
|
+
this.pendingSyncedTransactions = []
|
|
479
|
+
this.syncedKeys.clear()
|
|
480
|
+
this.hasReceivedFirstCommit = false
|
|
481
|
+
this.onFirstCommitCallbacks = []
|
|
482
|
+
this.preloadPromise = null
|
|
483
|
+
|
|
484
|
+
// Update status
|
|
485
|
+
this.setStatus(`cleaned-up`)
|
|
486
|
+
|
|
487
|
+
return Promise.resolve()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Start the garbage collection timer
|
|
492
|
+
* Called when the collection becomes inactive (no subscribers)
|
|
493
|
+
*/
|
|
494
|
+
private startGCTimer(): void {
|
|
495
|
+
if (this.gcTimeoutId) {
|
|
496
|
+
clearTimeout(this.gcTimeoutId)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
|
|
500
|
+
this.gcTimeoutId = setTimeout(() => {
|
|
501
|
+
if (this.activeSubscribersCount === 0) {
|
|
502
|
+
this.cleanup()
|
|
503
|
+
}
|
|
504
|
+
}, gcTime)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Cancel the garbage collection timer
|
|
509
|
+
* Called when the collection becomes active again
|
|
510
|
+
*/
|
|
511
|
+
private cancelGCTimer(): void {
|
|
512
|
+
if (this.gcTimeoutId) {
|
|
513
|
+
clearTimeout(this.gcTimeoutId)
|
|
514
|
+
this.gcTimeoutId = null
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Increment the active subscribers count and start sync if needed
|
|
520
|
+
*/
|
|
521
|
+
private addSubscriber(): void {
|
|
522
|
+
this.activeSubscribersCount++
|
|
523
|
+
this.cancelGCTimer()
|
|
524
|
+
|
|
525
|
+
// Start sync if collection was cleaned up
|
|
526
|
+
if (this._status === `cleaned-up` || this._status === `idle`) {
|
|
527
|
+
this.startSync()
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Decrement the active subscribers count and start GC timer if needed
|
|
533
|
+
*/
|
|
534
|
+
private removeSubscriber(): void {
|
|
535
|
+
this.activeSubscribersCount--
|
|
536
|
+
|
|
537
|
+
if (this.activeSubscribersCount === 0) {
|
|
538
|
+
this.activeSubscribersCount = 0
|
|
539
|
+
this.startGCTimer()
|
|
540
|
+
} else if (this.activeSubscribersCount < 0) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Active subscribers count is negative - this should never happen`
|
|
543
|
+
)
|
|
544
|
+
}
|
|
271
545
|
}
|
|
272
546
|
|
|
273
547
|
/**
|
|
274
548
|
* Recompute optimistic state from active transactions
|
|
275
549
|
*/
|
|
276
550
|
private recomputeOptimisticState(): void {
|
|
551
|
+
// Skip redundant recalculations when we're in the middle of committing sync transactions
|
|
552
|
+
if (this.isCommittingSyncTransactions) {
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
277
556
|
const previousState = new Map(this.derivedUpserts)
|
|
278
557
|
const previousDeletes = new Set(this.derivedDeletes)
|
|
279
558
|
|
|
@@ -281,23 +560,31 @@ export class CollectionImpl<
|
|
|
281
560
|
this.derivedUpserts.clear()
|
|
282
561
|
this.derivedDeletes.clear()
|
|
283
562
|
|
|
284
|
-
|
|
285
|
-
const
|
|
563
|
+
const activeTransactions: Array<Transaction<any>> = []
|
|
564
|
+
const completedTransactions: Array<Transaction<any>> = []
|
|
565
|
+
|
|
566
|
+
for (const transaction of this.transactions.values()) {
|
|
567
|
+
if (transaction.state === `completed`) {
|
|
568
|
+
completedTransactions.push(transaction)
|
|
569
|
+
} else if (![`completed`, `failed`].includes(transaction.state)) {
|
|
570
|
+
activeTransactions.push(transaction)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Apply active transactions only (completed transactions are handled by sync operations)
|
|
286
575
|
for (const transaction of activeTransactions) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
break
|
|
300
|
-
}
|
|
576
|
+
for (const mutation of transaction.mutations) {
|
|
577
|
+
if (mutation.collection === this) {
|
|
578
|
+
switch (mutation.type) {
|
|
579
|
+
case `insert`:
|
|
580
|
+
case `update`:
|
|
581
|
+
this.derivedUpserts.set(mutation.key, mutation.modified as T)
|
|
582
|
+
this.derivedDeletes.delete(mutation.key)
|
|
583
|
+
break
|
|
584
|
+
case `delete`:
|
|
585
|
+
this.derivedUpserts.delete(mutation.key)
|
|
586
|
+
this.derivedDeletes.add(mutation.key)
|
|
587
|
+
break
|
|
301
588
|
}
|
|
302
589
|
}
|
|
303
590
|
}
|
|
@@ -310,8 +597,58 @@ export class CollectionImpl<
|
|
|
310
597
|
const events: Array<ChangeMessage<T, TKey>> = []
|
|
311
598
|
this.collectOptimisticChanges(previousState, previousDeletes, events)
|
|
312
599
|
|
|
313
|
-
//
|
|
314
|
-
|
|
600
|
+
// Filter out events for recently synced keys to prevent duplicates
|
|
601
|
+
const filteredEventsBySyncStatus = events.filter(
|
|
602
|
+
(event) => !this.recentlySyncedKeys.has(event.key)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
// Filter out redundant delete events if there are pending sync transactions
|
|
606
|
+
// that will immediately restore the same data, but only for completed transactions
|
|
607
|
+
if (this.pendingSyncedTransactions.length > 0) {
|
|
608
|
+
const pendingSyncKeys = new Set<TKey>()
|
|
609
|
+
const completedTransactionMutations = new Set<string>()
|
|
610
|
+
|
|
611
|
+
// Collect keys from pending sync operations
|
|
612
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
613
|
+
for (const operation of transaction.operations) {
|
|
614
|
+
pendingSyncKeys.add(operation.key as TKey)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Collect mutation IDs from completed transactions
|
|
619
|
+
for (const tx of completedTransactions) {
|
|
620
|
+
for (const mutation of tx.mutations) {
|
|
621
|
+
if (mutation.collection === this) {
|
|
622
|
+
completedTransactionMutations.add(mutation.mutationId)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Only filter out delete events for keys that:
|
|
628
|
+
// 1. Have pending sync operations AND
|
|
629
|
+
// 2. Are from completed transactions (being cleaned up)
|
|
630
|
+
const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
|
|
631
|
+
if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
|
|
632
|
+
// Check if this delete is from clearing optimistic state of completed transactions
|
|
633
|
+
// We can infer this by checking if we have no remaining optimistic mutations for this key
|
|
634
|
+
const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
|
|
635
|
+
tx.mutations.some(
|
|
636
|
+
(m) => m.collection === this && m.key === event.key
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if (!hasActiveOptimisticMutation) {
|
|
641
|
+
return false // Skip this delete event as sync will restore the data
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return true
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
this.emitEvents(filteredEvents)
|
|
648
|
+
} else {
|
|
649
|
+
// Emit all events if no pending sync transactions
|
|
650
|
+
this.emitEvents(filteredEventsBySyncStatus)
|
|
651
|
+
}
|
|
315
652
|
}
|
|
316
653
|
|
|
317
654
|
/**
|
|
@@ -492,7 +829,10 @@ export class CollectionImpl<
|
|
|
492
829
|
for (const key of this.keys()) {
|
|
493
830
|
const value = this.get(key)
|
|
494
831
|
if (value !== undefined) {
|
|
495
|
-
|
|
832
|
+
const { _orderByIndex, ...copy } = value as T & {
|
|
833
|
+
_orderByIndex?: number | string
|
|
834
|
+
}
|
|
835
|
+
yield copy as T
|
|
496
836
|
}
|
|
497
837
|
}
|
|
498
838
|
}
|
|
@@ -504,7 +844,10 @@ export class CollectionImpl<
|
|
|
504
844
|
for (const key of this.keys()) {
|
|
505
845
|
const value = this.get(key)
|
|
506
846
|
if (value !== undefined) {
|
|
507
|
-
|
|
847
|
+
const { _orderByIndex, ...copy } = value as T & {
|
|
848
|
+
_orderByIndex?: number | string
|
|
849
|
+
}
|
|
850
|
+
yield [key, copy as T]
|
|
508
851
|
}
|
|
509
852
|
}
|
|
510
853
|
}
|
|
@@ -514,18 +857,46 @@ export class CollectionImpl<
|
|
|
514
857
|
* This method processes operations from pending transactions and applies them to the synced data
|
|
515
858
|
*/
|
|
516
859
|
commitPendingTransactions = () => {
|
|
517
|
-
if
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
|
|
860
|
+
// Check if there are any persisting transaction
|
|
861
|
+
let hasPersistingTransaction = false
|
|
862
|
+
for (const transaction of this.transactions.values()) {
|
|
863
|
+
if (transaction.state === `persisting`) {
|
|
864
|
+
hasPersistingTransaction = true
|
|
865
|
+
break
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!hasPersistingTransaction) {
|
|
870
|
+
// Set flag to prevent redundant optimistic state recalculations
|
|
871
|
+
this.isCommittingSyncTransactions = true
|
|
872
|
+
|
|
873
|
+
// First collect all keys that will be affected by sync operations
|
|
522
874
|
const changedKeys = new Set<TKey>()
|
|
875
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
876
|
+
for (const operation of transaction.operations) {
|
|
877
|
+
changedKeys.add(operation.key as TKey)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Use pre-captured state if available (from optimistic scenarios),
|
|
882
|
+
// otherwise capture current state (for pure sync scenarios)
|
|
883
|
+
let currentVisibleState = this.preSyncVisibleState
|
|
884
|
+
if (currentVisibleState.size === 0) {
|
|
885
|
+
// No pre-captured state, capture it now for pure sync operations
|
|
886
|
+
currentVisibleState = new Map<TKey, T>()
|
|
887
|
+
for (const key of changedKeys) {
|
|
888
|
+
const currentValue = this.get(key)
|
|
889
|
+
if (currentValue !== undefined) {
|
|
890
|
+
currentVisibleState.set(key, currentValue)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
523
895
|
const events: Array<ChangeMessage<T, TKey>> = []
|
|
524
896
|
|
|
525
897
|
for (const transaction of this.pendingSyncedTransactions) {
|
|
526
898
|
for (const operation of transaction.operations) {
|
|
527
899
|
const key = operation.key as TKey
|
|
528
|
-
changedKeys.add(key)
|
|
529
900
|
this.syncedKeys.add(key)
|
|
530
901
|
|
|
531
902
|
// Update metadata
|
|
@@ -548,22 +919,10 @@ export class CollectionImpl<
|
|
|
548
919
|
break
|
|
549
920
|
}
|
|
550
921
|
|
|
551
|
-
// Update synced data
|
|
552
|
-
const previousValue = this.syncedData.get(key)
|
|
553
|
-
|
|
922
|
+
// Update synced data
|
|
554
923
|
switch (operation.type) {
|
|
555
924
|
case `insert`:
|
|
556
925
|
this.syncedData.set(key, operation.value)
|
|
557
|
-
if (
|
|
558
|
-
!this.derivedDeletes.has(key) &&
|
|
559
|
-
!this.derivedUpserts.has(key)
|
|
560
|
-
) {
|
|
561
|
-
events.push({
|
|
562
|
-
type: `insert`,
|
|
563
|
-
key,
|
|
564
|
-
value: operation.value,
|
|
565
|
-
})
|
|
566
|
-
}
|
|
567
926
|
break
|
|
568
927
|
case `update`: {
|
|
569
928
|
const updatedValue = Object.assign(
|
|
@@ -572,38 +931,103 @@ export class CollectionImpl<
|
|
|
572
931
|
operation.value
|
|
573
932
|
)
|
|
574
933
|
this.syncedData.set(key, updatedValue)
|
|
575
|
-
if (
|
|
576
|
-
!this.derivedDeletes.has(key) &&
|
|
577
|
-
!this.derivedUpserts.has(key)
|
|
578
|
-
) {
|
|
579
|
-
events.push({
|
|
580
|
-
type: `update`,
|
|
581
|
-
key,
|
|
582
|
-
value: updatedValue,
|
|
583
|
-
previousValue,
|
|
584
|
-
})
|
|
585
|
-
}
|
|
586
934
|
break
|
|
587
935
|
}
|
|
588
936
|
case `delete`:
|
|
589
937
|
this.syncedData.delete(key)
|
|
590
|
-
if (
|
|
591
|
-
!this.derivedDeletes.has(key) &&
|
|
592
|
-
!this.derivedUpserts.has(key)
|
|
593
|
-
) {
|
|
594
|
-
if (previousValue) {
|
|
595
|
-
events.push({
|
|
596
|
-
type: `delete`,
|
|
597
|
-
key,
|
|
598
|
-
value: previousValue,
|
|
599
|
-
})
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
938
|
break
|
|
603
939
|
}
|
|
604
940
|
}
|
|
605
941
|
}
|
|
606
942
|
|
|
943
|
+
// Clear optimistic state since sync operations will now provide the authoritative data
|
|
944
|
+
this.derivedUpserts.clear()
|
|
945
|
+
this.derivedDeletes.clear()
|
|
946
|
+
|
|
947
|
+
// Reset flag and recompute optimistic state for any remaining active transactions
|
|
948
|
+
this.isCommittingSyncTransactions = false
|
|
949
|
+
for (const transaction of this.transactions.values()) {
|
|
950
|
+
if (![`completed`, `failed`].includes(transaction.state)) {
|
|
951
|
+
for (const mutation of transaction.mutations) {
|
|
952
|
+
if (mutation.collection === this) {
|
|
953
|
+
switch (mutation.type) {
|
|
954
|
+
case `insert`:
|
|
955
|
+
case `update`:
|
|
956
|
+
this.derivedUpserts.set(mutation.key, mutation.modified as T)
|
|
957
|
+
this.derivedDeletes.delete(mutation.key)
|
|
958
|
+
break
|
|
959
|
+
case `delete`:
|
|
960
|
+
this.derivedUpserts.delete(mutation.key)
|
|
961
|
+
this.derivedDeletes.add(mutation.key)
|
|
962
|
+
break
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Check for redundant sync operations that match completed optimistic operations
|
|
970
|
+
const completedOptimisticOps = new Map<TKey, any>()
|
|
971
|
+
|
|
972
|
+
for (const transaction of this.transactions.values()) {
|
|
973
|
+
if (transaction.state === `completed`) {
|
|
974
|
+
for (const mutation of transaction.mutations) {
|
|
975
|
+
if (mutation.collection === this && changedKeys.has(mutation.key)) {
|
|
976
|
+
completedOptimisticOps.set(mutation.key, {
|
|
977
|
+
type: mutation.type,
|
|
978
|
+
value: mutation.modified,
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Now check what actually changed in the final visible state
|
|
986
|
+
for (const key of changedKeys) {
|
|
987
|
+
const previousVisibleValue = currentVisibleState.get(key)
|
|
988
|
+
const newVisibleValue = this.get(key) // This returns the new derived state
|
|
989
|
+
|
|
990
|
+
// Check if this sync operation is redundant with a completed optimistic operation
|
|
991
|
+
const completedOp = completedOptimisticOps.get(key)
|
|
992
|
+
const isRedundantSync =
|
|
993
|
+
completedOp &&
|
|
994
|
+
newVisibleValue !== undefined &&
|
|
995
|
+
this.deepEqual(completedOp.value, newVisibleValue)
|
|
996
|
+
|
|
997
|
+
if (!isRedundantSync) {
|
|
998
|
+
if (
|
|
999
|
+
previousVisibleValue === undefined &&
|
|
1000
|
+
newVisibleValue !== undefined
|
|
1001
|
+
) {
|
|
1002
|
+
events.push({
|
|
1003
|
+
type: `insert`,
|
|
1004
|
+
key,
|
|
1005
|
+
value: newVisibleValue,
|
|
1006
|
+
})
|
|
1007
|
+
} else if (
|
|
1008
|
+
previousVisibleValue !== undefined &&
|
|
1009
|
+
newVisibleValue === undefined
|
|
1010
|
+
) {
|
|
1011
|
+
events.push({
|
|
1012
|
+
type: `delete`,
|
|
1013
|
+
key,
|
|
1014
|
+
value: previousVisibleValue,
|
|
1015
|
+
})
|
|
1016
|
+
} else if (
|
|
1017
|
+
previousVisibleValue !== undefined &&
|
|
1018
|
+
newVisibleValue !== undefined &&
|
|
1019
|
+
!this.deepEqual(previousVisibleValue, newVisibleValue)
|
|
1020
|
+
) {
|
|
1021
|
+
events.push({
|
|
1022
|
+
type: `update`,
|
|
1023
|
+
key,
|
|
1024
|
+
value: newVisibleValue,
|
|
1025
|
+
previousValue: previousVisibleValue,
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
607
1031
|
// Update cached size after synced data changes
|
|
608
1032
|
this._size = this.calculateSize()
|
|
609
1033
|
|
|
@@ -612,6 +1036,14 @@ export class CollectionImpl<
|
|
|
612
1036
|
|
|
613
1037
|
this.pendingSyncedTransactions = []
|
|
614
1038
|
|
|
1039
|
+
// Clear the pre-sync state since sync operations are complete
|
|
1040
|
+
this.preSyncVisibleState.clear()
|
|
1041
|
+
|
|
1042
|
+
// Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
|
|
1043
|
+
Promise.resolve().then(() => {
|
|
1044
|
+
this.recentlySyncedKeys.clear()
|
|
1045
|
+
})
|
|
1046
|
+
|
|
615
1047
|
// Call any registered one-time commit listeners
|
|
616
1048
|
if (!this.hasReceivedFirstCommit) {
|
|
617
1049
|
this.hasReceivedFirstCommit = true
|
|
@@ -647,6 +1079,29 @@ export class CollectionImpl<
|
|
|
647
1079
|
return `KEY::${this.id}/${key}`
|
|
648
1080
|
}
|
|
649
1081
|
|
|
1082
|
+
private deepEqual(a: any, b: any): boolean {
|
|
1083
|
+
if (a === b) return true
|
|
1084
|
+
if (a == null || b == null) return false
|
|
1085
|
+
if (typeof a !== typeof b) return false
|
|
1086
|
+
|
|
1087
|
+
if (typeof a === `object`) {
|
|
1088
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
1089
|
+
|
|
1090
|
+
const keysA = Object.keys(a)
|
|
1091
|
+
const keysB = Object.keys(b)
|
|
1092
|
+
if (keysA.length !== keysB.length) return false
|
|
1093
|
+
|
|
1094
|
+
const keysBSet = new Set(keysB)
|
|
1095
|
+
for (const key of keysA) {
|
|
1096
|
+
if (!keysBSet.has(key)) return false
|
|
1097
|
+
if (!this.deepEqual(a[key], b[key])) return false
|
|
1098
|
+
}
|
|
1099
|
+
return true
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return false
|
|
1103
|
+
}
|
|
1104
|
+
|
|
650
1105
|
private validateData(
|
|
651
1106
|
data: unknown,
|
|
652
1107
|
type: `insert` | `update`,
|
|
@@ -733,6 +1188,8 @@ export class CollectionImpl<
|
|
|
733
1188
|
* insert({ text: "Buy groceries" }, { key: "grocery-task" })
|
|
734
1189
|
*/
|
|
735
1190
|
insert = (data: T | Array<T>, config?: InsertConfig) => {
|
|
1191
|
+
this.validateCollectionUsable(`insert`)
|
|
1192
|
+
|
|
736
1193
|
const ambientTransaction = getActiveTransaction()
|
|
737
1194
|
|
|
738
1195
|
// If no ambient transaction exists, check for an onInsert handler early
|
|
@@ -878,6 +1335,8 @@ export class CollectionImpl<
|
|
|
878
1335
|
throw new Error(`The first argument to update is missing`)
|
|
879
1336
|
}
|
|
880
1337
|
|
|
1338
|
+
this.validateCollectionUsable(`update`)
|
|
1339
|
+
|
|
881
1340
|
const ambientTransaction = getActiveTransaction()
|
|
882
1341
|
|
|
883
1342
|
// If no ambient transaction exists, check for an onUpdate handler early
|
|
@@ -1043,6 +1502,8 @@ export class CollectionImpl<
|
|
|
1043
1502
|
keys: Array<TKey> | TKey,
|
|
1044
1503
|
config?: OperationConfig
|
|
1045
1504
|
): TransactionType<any> => {
|
|
1505
|
+
this.validateCollectionUsable(`delete`)
|
|
1506
|
+
|
|
1046
1507
|
const ambientTransaction = getActiveTransaction()
|
|
1047
1508
|
|
|
1048
1509
|
// If no ambient transaction exists, check for an onDelete handler early
|
|
@@ -1211,6 +1672,9 @@ export class CollectionImpl<
|
|
|
1211
1672
|
callback: (changes: Array<ChangeMessage<T>>) => void,
|
|
1212
1673
|
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1213
1674
|
): () => void {
|
|
1675
|
+
// Start sync and track subscriber
|
|
1676
|
+
this.addSubscriber()
|
|
1677
|
+
|
|
1214
1678
|
if (includeInitialState) {
|
|
1215
1679
|
// First send the current state as changes
|
|
1216
1680
|
callback(this.currentStateAsChanges())
|
|
@@ -1221,6 +1685,7 @@ export class CollectionImpl<
|
|
|
1221
1685
|
|
|
1222
1686
|
return () => {
|
|
1223
1687
|
this.changeListeners.delete(callback)
|
|
1688
|
+
this.removeSubscriber()
|
|
1224
1689
|
}
|
|
1225
1690
|
}
|
|
1226
1691
|
|
|
@@ -1232,6 +1697,9 @@ export class CollectionImpl<
|
|
|
1232
1697
|
listener: ChangeListener<T, TKey>,
|
|
1233
1698
|
{ includeInitialState = false }: { includeInitialState?: boolean } = {}
|
|
1234
1699
|
): () => void {
|
|
1700
|
+
// Start sync and track subscriber
|
|
1701
|
+
this.addSubscriber()
|
|
1702
|
+
|
|
1235
1703
|
if (!this.changeKeyListeners.has(key)) {
|
|
1236
1704
|
this.changeKeyListeners.set(key, new Set())
|
|
1237
1705
|
}
|
|
@@ -1257,6 +1725,40 @@ export class CollectionImpl<
|
|
|
1257
1725
|
this.changeKeyListeners.delete(key)
|
|
1258
1726
|
}
|
|
1259
1727
|
}
|
|
1728
|
+
this.removeSubscriber()
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Capture visible state for keys that will be affected by pending sync operations
|
|
1734
|
+
* This must be called BEFORE onTransactionStateChange clears optimistic state
|
|
1735
|
+
*/
|
|
1736
|
+
private capturePreSyncVisibleState(): void {
|
|
1737
|
+
if (this.pendingSyncedTransactions.length === 0) return
|
|
1738
|
+
|
|
1739
|
+
// Clear any previous capture
|
|
1740
|
+
this.preSyncVisibleState.clear()
|
|
1741
|
+
|
|
1742
|
+
// Get all keys that will be affected by sync operations
|
|
1743
|
+
const syncedKeys = new Set<TKey>()
|
|
1744
|
+
for (const transaction of this.pendingSyncedTransactions) {
|
|
1745
|
+
for (const operation of transaction.operations) {
|
|
1746
|
+
syncedKeys.add(operation.key as TKey)
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
|
|
1751
|
+
for (const key of syncedKeys) {
|
|
1752
|
+
this.recentlySyncedKeys.add(key)
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Only capture current visible state for keys that will be affected by sync operations
|
|
1756
|
+
// This is much more efficient than capturing the entire collection state
|
|
1757
|
+
for (const key of syncedKeys) {
|
|
1758
|
+
const currentValue = this.get(key)
|
|
1759
|
+
if (currentValue !== undefined) {
|
|
1760
|
+
this.preSyncVisibleState.set(key, currentValue)
|
|
1761
|
+
}
|
|
1260
1762
|
}
|
|
1261
1763
|
}
|
|
1262
1764
|
|
|
@@ -1265,6 +1767,9 @@ export class CollectionImpl<
|
|
|
1265
1767
|
* This method should be called by the Transaction class when state changes
|
|
1266
1768
|
*/
|
|
1267
1769
|
public onTransactionStateChange(): void {
|
|
1770
|
+
// CRITICAL: Capture visible state BEFORE clearing optimistic state
|
|
1771
|
+
this.capturePreSyncVisibleState()
|
|
1772
|
+
|
|
1268
1773
|
this.recomputeOptimisticState()
|
|
1269
1774
|
}
|
|
1270
1775
|
|
|
@@ -1279,7 +1784,7 @@ export class CollectionImpl<
|
|
|
1279
1784
|
public asStoreMap(): Store<Map<TKey, T>> {
|
|
1280
1785
|
if (!this._storeMap) {
|
|
1281
1786
|
this._storeMap = new Store(new Map(this.entries()))
|
|
1282
|
-
this.
|
|
1787
|
+
this.changeListeners.add(() => {
|
|
1283
1788
|
this._storeMap!.setState(() => new Map(this.entries()))
|
|
1284
1789
|
})
|
|
1285
1790
|
}
|
|
@@ -1297,7 +1802,7 @@ export class CollectionImpl<
|
|
|
1297
1802
|
public asStoreArray(): Store<Array<T>> {
|
|
1298
1803
|
if (!this._storeArray) {
|
|
1299
1804
|
this._storeArray = new Store(this.toArray)
|
|
1300
|
-
this.
|
|
1805
|
+
this.changeListeners.add(() => {
|
|
1301
1806
|
this._storeArray!.setState(() => this.toArray)
|
|
1302
1807
|
})
|
|
1303
1808
|
}
|