@tanstack/powersync-db-collection 0.1.66 → 0.1.69
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/PowerSyncTransactor.cjs +16 -1
- package/dist/cjs/PowerSyncTransactor.cjs.map +1 -1
- package/dist/cjs/powersync.cjs +230 -90
- package/dist/cjs/powersync.cjs.map +1 -1
- package/dist/esm/PowerSyncTransactor.js +16 -1
- package/dist/esm/PowerSyncTransactor.js.map +1 -1
- package/dist/esm/powersync.js +230 -90
- package/dist/esm/powersync.js.map +1 -1
- package/package.json +2 -2
- package/src/PowerSyncTransactor.ts +24 -1
- package/src/powersync.ts +305 -126
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeSQL } from '@powersync/common'
|
|
2
|
+
import { LoadSubsetOperationAbortedError } from '@tanstack/db'
|
|
2
3
|
import DebugModule from 'debug'
|
|
3
4
|
import { PendingOperationStore } from './PendingOperationStore'
|
|
4
5
|
import { asPowerSyncRecord, mapOperationToPowerSync } from './helpers'
|
|
@@ -94,7 +95,29 @@ export class PowerSyncTransactor {
|
|
|
94
95
|
if (collection.isReady()) {
|
|
95
96
|
return
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
// Observe this session without starting new demand from mutationFn.
|
|
99
|
+
// Cleanup and startup failure must settle the wait before taking a lock.
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
const check = () => {
|
|
102
|
+
if (collection.isReady()) {
|
|
103
|
+
unsubscribe()
|
|
104
|
+
resolve()
|
|
105
|
+
} else if (
|
|
106
|
+
collection.status === `error` ||
|
|
107
|
+
collection.status === `cleaned-up`
|
|
108
|
+
) {
|
|
109
|
+
unsubscribe()
|
|
110
|
+
reject(
|
|
111
|
+
collection.status === `error`
|
|
112
|
+
? (collection._lifecycle.getSyncError() ??
|
|
113
|
+
new Error(`Collection failed before readiness`))
|
|
114
|
+
: new LoadSubsetOperationAbortedError(),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const unsubscribe = collection.on(`status:change`, check)
|
|
119
|
+
check()
|
|
120
|
+
})
|
|
98
121
|
}),
|
|
99
122
|
)
|
|
100
123
|
|
package/src/powersync.ts
CHANGED
|
@@ -323,6 +323,7 @@ function createPowerSyncCollectionConfig<
|
|
|
323
323
|
let disposeTracking:
|
|
324
324
|
| ((options?: { context?: LockContext }) => Promise<void>)
|
|
325
325
|
| null = null
|
|
326
|
+
let trackingSetup: Promise<void> | null = null
|
|
326
327
|
|
|
327
328
|
if (syncMode === `eager`) {
|
|
328
329
|
return runEagerSync()
|
|
@@ -337,6 +338,13 @@ function createPowerSyncCollectionConfig<
|
|
|
337
338
|
async function safelyDisposeTracking(
|
|
338
339
|
context?: LockContext,
|
|
339
340
|
): Promise<void> {
|
|
341
|
+
// Cleanup can race trigger creation. Wait until the disposer has been
|
|
342
|
+
// published so an abort cannot strand a freshly-created trigger.
|
|
343
|
+
const setup = trackingSetup
|
|
344
|
+
if (setup) {
|
|
345
|
+
await setup.catch(() => undefined)
|
|
346
|
+
}
|
|
347
|
+
|
|
340
348
|
const dispose = disposeTracking
|
|
341
349
|
if (!dispose) {
|
|
342
350
|
return
|
|
@@ -346,6 +354,25 @@ function createPowerSyncCollectionConfig<
|
|
|
346
354
|
await dispose(context ? { context } : undefined)
|
|
347
355
|
}
|
|
348
356
|
|
|
357
|
+
async function establishTracking(
|
|
358
|
+
options: Parameters<typeof createDiffTrigger>[0],
|
|
359
|
+
appliedReceipts: Array<SyncAppliedReceipt>,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const setup = (async () => {
|
|
362
|
+
const dispose = await createDiffTrigger(options, appliedReceipts)
|
|
363
|
+
disposeTracking = dispose
|
|
364
|
+
})()
|
|
365
|
+
trackingSetup = setup
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
await setup
|
|
369
|
+
} finally {
|
|
370
|
+
if (trackingSetup === setup) {
|
|
371
|
+
trackingSetup = null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
349
376
|
async function createDiffTrigger(
|
|
350
377
|
options: {
|
|
351
378
|
setupContext?: LockContext
|
|
@@ -398,6 +425,17 @@ function createPowerSyncCollectionConfig<
|
|
|
398
425
|
}
|
|
399
426
|
|
|
400
427
|
async function flushDiffRecords(): Promise<void> {
|
|
428
|
+
// PowerSync can notify after creating the tracking table but before its
|
|
429
|
+
// create call returns. Preserve that notification until the disposer,
|
|
430
|
+
// which proves the trigger is usable, has been published.
|
|
431
|
+
const setup = trackingSetup
|
|
432
|
+
if (setup) {
|
|
433
|
+
await setup.catch(() => undefined)
|
|
434
|
+
}
|
|
435
|
+
if (!disposeTracking) {
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
|
|
401
439
|
const ignoredReceipts: Array<SyncAppliedReceipt> = []
|
|
402
440
|
await database
|
|
403
441
|
.writeTransaction(async (context) => {
|
|
@@ -512,10 +550,15 @@ function createPowerSyncCollectionConfig<
|
|
|
512
550
|
let onUnload: CleanupFn | void | null = null
|
|
513
551
|
|
|
514
552
|
start(async () => {
|
|
515
|
-
|
|
553
|
+
const cleanup = await restConfig.onLoad?.()
|
|
554
|
+
if (abortController.signal.aborted) {
|
|
555
|
+
cleanup?.()
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
onUnload = cleanup
|
|
516
559
|
|
|
517
560
|
const appliedReceipts: Array<SyncAppliedReceipt> = []
|
|
518
|
-
|
|
561
|
+
await establishTracking(
|
|
519
562
|
{
|
|
520
563
|
// Initial eager hydration must make the source usable before
|
|
521
564
|
// PowerSync can persist a mutation queued during startup.
|
|
@@ -562,110 +605,181 @@ function createPowerSyncCollectionConfig<
|
|
|
562
605
|
// On-demand mode.
|
|
563
606
|
// Registers a diff trigger for the active WHERE expressions.
|
|
564
607
|
function runOnDemandSync() {
|
|
565
|
-
|
|
608
|
+
type DemandRecord = {
|
|
609
|
+
options: LoadSubsetOptions
|
|
610
|
+
active: boolean
|
|
611
|
+
cleanup?: CleanupFn
|
|
612
|
+
}
|
|
613
|
+
type PendingRelease = {
|
|
614
|
+
options: LoadSubsetOptions
|
|
615
|
+
failures: number
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const demands = new Map<LoadSubsetOptions, DemandRecord>()
|
|
566
619
|
const releasedSubsets = new WeakSet<LoadSubsetOptions>()
|
|
620
|
+
const pendingReleases: Array<PendingRelease> = []
|
|
567
621
|
let stopped = false
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
622
|
+
let trackingRevision = 0
|
|
623
|
+
let reconciledTrackingRevision = 0
|
|
624
|
+
let rebuildPromise: Promise<void> | null = null
|
|
625
|
+
let drainingReleases = false
|
|
626
|
+
let releaseRetryTimer: ReturnType<typeof setTimeout> | undefined
|
|
627
|
+
const startup = start()
|
|
628
|
+
void startup.catch((error) =>
|
|
571
629
|
database.logger.error(
|
|
572
630
|
`Could not start syncing process for ${viewName} into ${trackedTableName}`,
|
|
573
631
|
error,
|
|
574
632
|
),
|
|
575
633
|
)
|
|
576
634
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
635
|
+
const activeWhereExpressions = () =>
|
|
636
|
+
Array.from(demands.values())
|
|
637
|
+
.filter((demand) => demand.active)
|
|
638
|
+
.map((demand) => demand.options.where)
|
|
580
639
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
): Promise<void> => {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
activeWhereExpressions.push(options.where)
|
|
589
|
-
const cleanup = await restConfig.onLoadSubset?.(options)
|
|
590
|
-
if (hasStopped()) {
|
|
591
|
-
cleanup?.()
|
|
592
|
-
return
|
|
593
|
-
}
|
|
594
|
-
if (cleanup) {
|
|
595
|
-
if (releasedSubsets.has(options) || options.signal?.aborted) {
|
|
596
|
-
cleanup()
|
|
597
|
-
} else {
|
|
598
|
-
unloadSubsetCallbacks.set(options, cleanup)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
}
|
|
640
|
+
// One reconciliation owns every queued revision so callers cannot
|
|
641
|
+
// settle against a stale trigger configuration.
|
|
642
|
+
const reconcileTracking = async (): Promise<void> => {
|
|
643
|
+
while (!stopped && reconciledTrackingRevision !== trackingRevision) {
|
|
644
|
+
const revision = trackingRevision
|
|
645
|
+
const isCurrent = () => !stopped && trackingRevision === revision
|
|
646
|
+
const appliedReceipts: Array<SyncAppliedReceipt> = []
|
|
602
647
|
|
|
603
|
-
// No predicates remain, so stop tracking entirely. Both calls are no-ops
|
|
604
|
-
// when no tracking table is currently active.
|
|
605
|
-
if (activeWhereExpressions.length === 0) {
|
|
606
648
|
await database.writeLock(async (ctx) => {
|
|
649
|
+
if (!isCurrent()) return
|
|
607
650
|
await flushDiffRecordsWithContext(ctx, appliedReceipts)
|
|
651
|
+
if (!isCurrent()) return
|
|
608
652
|
await safelyDisposeTracking(ctx)
|
|
653
|
+
if (!isCurrent()) return
|
|
654
|
+
|
|
655
|
+
const active = activeWhereExpressions()
|
|
656
|
+
// Tracking was absent during an error. Positive baseline rows
|
|
657
|
+
// alone cannot reveal deletes or predicate exits from that gap.
|
|
658
|
+
const missing =
|
|
659
|
+
collection.status === `error`
|
|
660
|
+
? new Set(collection.keys())
|
|
661
|
+
: undefined
|
|
662
|
+
if (active.length > 0) {
|
|
663
|
+
const combinedWhere =
|
|
664
|
+
active.length === 1
|
|
665
|
+
? active[0]
|
|
666
|
+
: or(active[0], active[1], ...active.slice(2))
|
|
667
|
+
const compiledNewData = compileSQLite(
|
|
668
|
+
{ where: combinedWhere },
|
|
669
|
+
{ jsonColumn: 'NEW.data' },
|
|
670
|
+
)
|
|
671
|
+
const compiledOldData = compileSQLite(
|
|
672
|
+
{ where: combinedWhere },
|
|
673
|
+
{ jsonColumn: 'OLD.data' },
|
|
674
|
+
)
|
|
675
|
+
const compiledView = compileSQLite({ where: combinedWhere })
|
|
676
|
+
const newDataWhenClause = toInlinedWhereClause(compiledNewData)
|
|
677
|
+
const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
|
|
678
|
+
const viewWhereClause = toInlinedWhereClause(compiledView)
|
|
679
|
+
await establishTracking(
|
|
680
|
+
{
|
|
681
|
+
setupContext: ctx,
|
|
682
|
+
when: {
|
|
683
|
+
[DiffTriggerOperation.INSERT]: newDataWhenClause,
|
|
684
|
+
[DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
|
|
685
|
+
[DiffTriggerOperation.DELETE]: oldDataWhenClause,
|
|
686
|
+
},
|
|
687
|
+
writeType: (rowId: string) =>
|
|
688
|
+
collection.has(rowId) ? `update` : `insert`,
|
|
689
|
+
batchQuery: (
|
|
690
|
+
lockContext: LockContext,
|
|
691
|
+
batchSize: number,
|
|
692
|
+
cursor: number,
|
|
693
|
+
) =>
|
|
694
|
+
lockContext
|
|
695
|
+
.getAll<TableType>(
|
|
696
|
+
`SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
|
|
697
|
+
[batchSize, cursor],
|
|
698
|
+
)
|
|
699
|
+
.then((rows) => {
|
|
700
|
+
for (const row of rows) missing?.delete(row.id)
|
|
701
|
+
return rows
|
|
702
|
+
}),
|
|
703
|
+
},
|
|
704
|
+
appliedReceipts,
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
if (!isCurrent()) await safelyDisposeTracking(ctx)
|
|
708
|
+
else if (missing?.size) {
|
|
709
|
+
begin()
|
|
710
|
+
for (const key of missing) write({ type: `delete`, key })
|
|
711
|
+
appliedReceipts.push(commit())
|
|
712
|
+
}
|
|
609
713
|
})
|
|
610
714
|
await Promise.all(appliedReceipts)
|
|
611
|
-
|
|
715
|
+
if (isCurrent()) {
|
|
716
|
+
reconciledTrackingRevision = revision
|
|
717
|
+
// Replacing the trigger alone is not recovery: its baseline
|
|
718
|
+
// writes must also be applied before the source is ready again.
|
|
719
|
+
if (collection.status === `error`) markReady()
|
|
720
|
+
}
|
|
612
721
|
}
|
|
722
|
+
}
|
|
613
723
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
724
|
+
const rebuildTracking = async (): Promise<void> => {
|
|
725
|
+
// New demand can join after reconciliation exits but before its
|
|
726
|
+
// shared promise clears. Each waiter must check its revision again.
|
|
727
|
+
while (!stopped && reconciledTrackingRevision !== trackingRevision) {
|
|
728
|
+
await (rebuildPromise ??= reconcileTracking()
|
|
729
|
+
.catch((error) => {
|
|
730
|
+
// A rebuild may already have removed every active diff trigger.
|
|
731
|
+
// Do not leave healthy consumers ready against a stale source.
|
|
732
|
+
if (!stopped) markError(error)
|
|
733
|
+
throw error
|
|
734
|
+
})
|
|
735
|
+
.finally(() => {
|
|
736
|
+
rebuildPromise = null
|
|
737
|
+
}))
|
|
738
|
+
}
|
|
739
|
+
}
|
|
622
740
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
)
|
|
741
|
+
const loadSubset = async (
|
|
742
|
+
options: LoadSubsetOptions,
|
|
743
|
+
): Promise<void> => {
|
|
744
|
+
if (stopped) return
|
|
745
|
+
// Never create a trigger that has no observer to drain its diff table.
|
|
746
|
+
await startup
|
|
747
|
+
if (
|
|
748
|
+
// Cleanup can run while startup is pending.
|
|
749
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
750
|
+
stopped ||
|
|
751
|
+
releasedSubsets.has(options) ||
|
|
752
|
+
options.signal?.aborted
|
|
753
|
+
) {
|
|
754
|
+
return
|
|
755
|
+
}
|
|
627
756
|
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
757
|
+
const demand: DemandRecord = { options, active: false }
|
|
758
|
+
demands.set(options, demand)
|
|
759
|
+
try {
|
|
760
|
+
const cleanup = await restConfig.onLoadSubset?.(options)
|
|
761
|
+
if (cleanup) demand.cleanup = cleanup
|
|
762
|
+
} catch (error) {
|
|
763
|
+
demands.delete(options)
|
|
764
|
+
throw error
|
|
765
|
+
}
|
|
632
766
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
[DiffTriggerOperation.INSERT]: newDataWhenClause,
|
|
650
|
-
[DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
|
|
651
|
-
[DiffTriggerOperation.DELETE]: oldDataWhenClause,
|
|
652
|
-
},
|
|
653
|
-
writeType: (rowId: string) =>
|
|
654
|
-
collection.has(rowId) ? `update` : `insert`,
|
|
655
|
-
batchQuery: (
|
|
656
|
-
lockContext: LockContext,
|
|
657
|
-
batchSize: number,
|
|
658
|
-
cursor: number,
|
|
659
|
-
) =>
|
|
660
|
-
lockContext.getAll<TableType>(
|
|
661
|
-
`SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
|
|
662
|
-
[batchSize, cursor],
|
|
663
|
-
),
|
|
664
|
-
},
|
|
665
|
-
appliedReceipts,
|
|
666
|
-
)
|
|
667
|
-
})
|
|
668
|
-
await Promise.all(appliedReceipts)
|
|
767
|
+
if (
|
|
768
|
+
// The user hook can reenter cleanup.
|
|
769
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
770
|
+
stopped ||
|
|
771
|
+
releasedSubsets.has(options) ||
|
|
772
|
+
options.signal?.aborted ||
|
|
773
|
+
demands.get(options) !== demand
|
|
774
|
+
) {
|
|
775
|
+
demands.delete(options)
|
|
776
|
+
demand.cleanup?.()
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
demand.active = true
|
|
781
|
+
trackingRevision++
|
|
782
|
+
await rebuildTracking()
|
|
669
783
|
}
|
|
670
784
|
|
|
671
785
|
const toInlinedWhereClause = (compiled: {
|
|
@@ -680,56 +794,118 @@ function createPowerSyncCollectionConfig<
|
|
|
680
794
|
)
|
|
681
795
|
}
|
|
682
796
|
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
797
|
+
const cleanupDemand = (demand: DemandRecord): void => {
|
|
798
|
+
demands.delete(demand.options)
|
|
799
|
+
try {
|
|
800
|
+
demand.cleanup?.()
|
|
801
|
+
} catch (error) {
|
|
802
|
+
database.logger.error(
|
|
803
|
+
`Could not clean up subset hook for ${viewName}`,
|
|
804
|
+
error,
|
|
805
|
+
)
|
|
691
806
|
}
|
|
807
|
+
}
|
|
692
808
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
809
|
+
const performPhysicalRelease = async (
|
|
810
|
+
options: LoadSubsetOptions,
|
|
811
|
+
): Promise<void> => {
|
|
696
812
|
const compiledDeparting = compileSQLite({ where: options.where })
|
|
697
813
|
const departingWhereSQL = toInlinedWhereClause(compiledDeparting)
|
|
814
|
+
let rowsToEvict: Array<{ id: string }>
|
|
815
|
+
for (;;) {
|
|
816
|
+
if (stopped) return
|
|
817
|
+
const revision = trackingRevision
|
|
818
|
+
const active = activeWhereExpressions()
|
|
819
|
+
let evictionSQL: string
|
|
820
|
+
if (active.length === 0) {
|
|
821
|
+
evictionSQL = `SELECT id FROM ${viewName} WHERE ${departingWhereSQL}`
|
|
822
|
+
} else {
|
|
823
|
+
const combinedRemaining =
|
|
824
|
+
active.length === 1
|
|
825
|
+
? active[0]!
|
|
826
|
+
: or(active[0], active[1], ...active.slice(2))
|
|
827
|
+
const compiledRemaining = compileSQLite({
|
|
828
|
+
where: combinedRemaining,
|
|
829
|
+
})
|
|
830
|
+
const remainingWhereSQL = toInlinedWhereClause(compiledRemaining)
|
|
831
|
+
evictionSQL = `SELECT id FROM ${viewName} WHERE (${departingWhereSQL}) AND NOT (${remainingWhereSQL})`
|
|
832
|
+
}
|
|
698
833
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const combinedRemaining =
|
|
704
|
-
activeWhereExpressions.length === 1
|
|
705
|
-
? activeWhereExpressions[0]!
|
|
706
|
-
: or(
|
|
707
|
-
activeWhereExpressions[0],
|
|
708
|
-
activeWhereExpressions[1],
|
|
709
|
-
...activeWhereExpressions.slice(2),
|
|
710
|
-
)
|
|
711
|
-
const compiledRemaining = compileSQLite({
|
|
712
|
-
where: combinedRemaining,
|
|
713
|
-
})
|
|
714
|
-
const remainingWhereSQL = toInlinedWhereClause(compiledRemaining)
|
|
715
|
-
evictionSQL = `SELECT id FROM ${viewName} WHERE (${departingWhereSQL}) AND NOT (${remainingWhereSQL})`
|
|
834
|
+
rowsToEvict = await database.getAll<{ id: string }>(evictionSQL)
|
|
835
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cleanup can run during the query
|
|
836
|
+
if (stopped) return
|
|
837
|
+
if (trackingRevision === revision) break
|
|
716
838
|
}
|
|
717
|
-
|
|
718
|
-
const rowsToEvict = await database.getAll<{ id: string }>(evictionSQL)
|
|
719
839
|
if (rowsToEvict.length > 0) {
|
|
720
840
|
begin()
|
|
721
841
|
for (const { id } of rowsToEvict) {
|
|
722
842
|
write({ type: `delete`, key: id })
|
|
723
843
|
}
|
|
724
|
-
// Eviction does not establish new subset coverage. Keep trigger
|
|
725
|
-
// replacement in the same unload turn even when this delete waits
|
|
726
|
-
// behind a persisting mutation; the later load tracks its own
|
|
727
|
-
// establishing receipts.
|
|
728
844
|
void commit()
|
|
729
845
|
}
|
|
846
|
+
await rebuildTracking()
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function scheduleReleaseDrain(delay = 0): void {
|
|
850
|
+
if (stopped || drainingReleases || releaseRetryTimer) return
|
|
851
|
+
if (delay > 0) {
|
|
852
|
+
releaseRetryTimer = setTimeout(() => {
|
|
853
|
+
releaseRetryTimer = undefined
|
|
854
|
+
void drainReleases()
|
|
855
|
+
}, delay)
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
void drainReleases()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function drainReleases(): Promise<void> {
|
|
862
|
+
if (stopped || drainingReleases) return
|
|
863
|
+
drainingReleases = true
|
|
864
|
+
let retryDelay = 0
|
|
865
|
+
try {
|
|
866
|
+
const attempts = pendingReleases.length
|
|
867
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- each release can reenter cleanup
|
|
868
|
+
for (let index = 0; !stopped && index < attempts; index++) {
|
|
869
|
+
const pending = pendingReleases.shift()!
|
|
870
|
+
try {
|
|
871
|
+
await performPhysicalRelease(pending.options)
|
|
872
|
+
} catch (error) {
|
|
873
|
+
pending.failures++
|
|
874
|
+
pendingReleases.push(pending)
|
|
875
|
+
const delay = Math.min(
|
|
876
|
+
1000 * 2 ** (pending.failures - 1),
|
|
877
|
+
30000,
|
|
878
|
+
)
|
|
879
|
+
retryDelay =
|
|
880
|
+
retryDelay === 0 ? delay : Math.min(retryDelay, delay)
|
|
881
|
+
database.logger.error(
|
|
882
|
+
`Could not release subset tracking for ${viewName}; retrying`,
|
|
883
|
+
error,
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} finally {
|
|
888
|
+
drainingReleases = false
|
|
889
|
+
}
|
|
890
|
+
if (pendingReleases.length > 0) scheduleReleaseDrain(retryDelay)
|
|
891
|
+
}
|
|
730
892
|
|
|
731
|
-
|
|
732
|
-
|
|
893
|
+
const unloadSubset = (options: LoadSubsetOptions): void => {
|
|
894
|
+
releasedSubsets.add(options)
|
|
895
|
+
const demand = demands.get(options)
|
|
896
|
+
if (!demand) return
|
|
897
|
+
|
|
898
|
+
const wasActive = demand.active
|
|
899
|
+
if (wasActive) trackingRevision++
|
|
900
|
+
cleanupDemand(demand)
|
|
901
|
+
|
|
902
|
+
if (wasActive) {
|
|
903
|
+
pendingReleases.push({ options, failures: 0 })
|
|
904
|
+
// New work must not wait for another release's backoff.
|
|
905
|
+
clearTimeout(releaseRetryTimer)
|
|
906
|
+
releaseRetryTimer = undefined
|
|
907
|
+
scheduleReleaseDrain()
|
|
908
|
+
}
|
|
733
909
|
}
|
|
734
910
|
|
|
735
911
|
markReady()
|
|
@@ -737,16 +913,19 @@ function createPowerSyncCollectionConfig<
|
|
|
737
913
|
return {
|
|
738
914
|
cleanup: () => {
|
|
739
915
|
stopped = true
|
|
916
|
+
clearTimeout(releaseRetryTimer)
|
|
917
|
+
releaseRetryTimer = undefined
|
|
740
918
|
database.logger.info(
|
|
741
919
|
`Sync has been stopped for ${viewName} into ${trackedTableName}`,
|
|
742
920
|
)
|
|
743
921
|
abortController.abort()
|
|
744
|
-
for (const
|
|
745
|
-
|
|
746
|
-
|
|
922
|
+
for (const demand of demands.values()) {
|
|
923
|
+
cleanupDemand(demand)
|
|
924
|
+
}
|
|
925
|
+
pendingReleases.length = 0
|
|
747
926
|
},
|
|
748
927
|
loadSubset: (options: LoadSubsetOptions) => loadSubset(options),
|
|
749
|
-
unloadSubset
|
|
928
|
+
unloadSubset,
|
|
750
929
|
}
|
|
751
930
|
}
|
|
752
931
|
},
|