@tanstack/powersync-db-collection 0.1.66 → 0.1.67

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/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
- onUnload = await restConfig.onLoad?.()
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
- disposeTracking = await createDiffTrigger(
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,162 @@ function createPowerSyncCollectionConfig<
562
605
  // On-demand mode.
563
606
  // Registers a diff trigger for the active WHERE expressions.
564
607
  function runOnDemandSync() {
565
- const unloadSubsetCallbacks = new Map<LoadSubsetOptions, CleanupFn>()
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
- const hasStopped = () => stopped
569
-
570
- start().catch((error) =>
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
- // Tracks all active WHERE expressions for on-demand sync filtering.
578
- // Each loadSubset call pushes its predicate; unloadSubset removes it.
579
- const activeWhereExpressions: Array<LoadSubsetOptions['where']> = []
635
+ const activeWhereExpressions = () =>
636
+ Array.from(demands.values())
637
+ .filter((demand) => demand.active)
638
+ .map((demand) => demand.options.where)
580
639
 
581
- const loadSubset = async (
582
- options?: LoadSubsetOptions,
583
- ): Promise<void> => {
584
- if (hasStopped()) return
585
- const appliedReceipts: Array<SyncAppliedReceipt> = []
586
-
587
- if (options) {
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
+ if (active.length === 0) return
657
+ const combinedWhere =
658
+ active.length === 1
659
+ ? active[0]
660
+ : or(active[0], active[1], ...active.slice(2))
661
+ const compiledNewData = compileSQLite(
662
+ { where: combinedWhere },
663
+ { jsonColumn: 'NEW.data' },
664
+ )
665
+ const compiledOldData = compileSQLite(
666
+ { where: combinedWhere },
667
+ { jsonColumn: 'OLD.data' },
668
+ )
669
+ const compiledView = compileSQLite({ where: combinedWhere })
670
+ const newDataWhenClause = toInlinedWhereClause(compiledNewData)
671
+ const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
672
+ const viewWhereClause = toInlinedWhereClause(compiledView)
673
+
674
+ await establishTracking(
675
+ {
676
+ setupContext: ctx,
677
+ when: {
678
+ [DiffTriggerOperation.INSERT]: newDataWhenClause,
679
+ [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
680
+ [DiffTriggerOperation.DELETE]: oldDataWhenClause,
681
+ },
682
+ writeType: (rowId: string) =>
683
+ collection.has(rowId) ? `update` : `insert`,
684
+ batchQuery: (
685
+ lockContext: LockContext,
686
+ batchSize: number,
687
+ cursor: number,
688
+ ) =>
689
+ lockContext.getAll<TableType>(
690
+ `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
691
+ [batchSize, cursor],
692
+ ),
693
+ },
694
+ appliedReceipts,
695
+ )
696
+ if (!isCurrent()) await safelyDisposeTracking(ctx)
609
697
  })
610
698
  await Promise.all(appliedReceipts)
611
- return
699
+ if (isCurrent()) {
700
+ reconciledTrackingRevision = revision
701
+ }
612
702
  }
703
+ }
613
704
 
614
- const combinedWhere =
615
- activeWhereExpressions.length === 1
616
- ? activeWhereExpressions[0]
617
- : or(
618
- activeWhereExpressions[0],
619
- activeWhereExpressions[1],
620
- ...activeWhereExpressions.slice(2),
621
- )
705
+ const rebuildTracking = async (): Promise<void> => {
706
+ // New demand can join after reconciliation exits but before its
707
+ // shared promise clears. Each waiter must check its revision again.
708
+ while (!stopped && reconciledTrackingRevision !== trackingRevision) {
709
+ await (rebuildPromise ??= reconcileTracking()
710
+ .catch((error) => {
711
+ // A rebuild may already have removed every active diff trigger.
712
+ // Do not leave healthy consumers ready against a stale source.
713
+ if (!stopped) markError(error)
714
+ throw error
715
+ })
716
+ .finally(() => {
717
+ rebuildPromise = null
718
+ }))
719
+ }
720
+ }
622
721
 
623
- const compiledNewData = compileSQLite(
624
- { where: combinedWhere },
625
- { jsonColumn: 'NEW.data' },
626
- )
722
+ const loadSubset = async (
723
+ options: LoadSubsetOptions,
724
+ ): Promise<void> => {
725
+ if (stopped) return
726
+ // Never create a trigger that has no observer to drain its diff table.
727
+ await startup
728
+ if (
729
+ // Cleanup can run while startup is pending.
730
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
731
+ stopped ||
732
+ releasedSubsets.has(options) ||
733
+ options.signal?.aborted
734
+ ) {
735
+ return
736
+ }
627
737
 
628
- const compiledOldData = compileSQLite(
629
- { where: combinedWhere },
630
- { jsonColumn: 'OLD.data' },
631
- )
738
+ const demand: DemandRecord = { options, active: false }
739
+ demands.set(options, demand)
740
+ try {
741
+ const cleanup = await restConfig.onLoadSubset?.(options)
742
+ if (cleanup) demand.cleanup = cleanup
743
+ } catch (error) {
744
+ demands.delete(options)
745
+ throw error
746
+ }
632
747
 
633
- const compiledView = compileSQLite({ where: combinedWhere })
634
-
635
- const newDataWhenClause = toInlinedWhereClause(compiledNewData)
636
- const oldDataWhenClause = toInlinedWhereClause(compiledOldData)
637
- const viewWhereClause = toInlinedWhereClause(compiledView)
638
-
639
- await database.writeLock(async (ctx) => {
640
- // Replace any active tracking with one covering the new set of
641
- // predicates.
642
- await flushDiffRecordsWithContext(ctx, appliedReceipts)
643
- await safelyDisposeTracking(ctx)
644
-
645
- disposeTracking = await createDiffTrigger(
646
- {
647
- setupContext: ctx,
648
- when: {
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)
748
+ if (
749
+ // The user hook can reenter cleanup.
750
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
751
+ stopped ||
752
+ releasedSubsets.has(options) ||
753
+ options.signal?.aborted ||
754
+ demands.get(options) !== demand
755
+ ) {
756
+ demands.delete(options)
757
+ demand.cleanup?.()
758
+ return
759
+ }
760
+
761
+ demand.active = true
762
+ trackingRevision++
763
+ await rebuildTracking()
669
764
  }
670
765
 
671
766
  const toInlinedWhereClause = (compiled: {
@@ -680,56 +775,118 @@ function createPowerSyncCollectionConfig<
680
775
  )
681
776
  }
682
777
 
683
- const unloadSubset = async (options: LoadSubsetOptions) => {
684
- releasedSubsets.add(options)
685
- unloadSubsetCallbacks.get(options)?.()
686
- unloadSubsetCallbacks.delete(options)
687
-
688
- const idx = activeWhereExpressions.indexOf(options.where)
689
- if (idx !== -1) {
690
- activeWhereExpressions.splice(idx, 1)
778
+ const cleanupDemand = (demand: DemandRecord): void => {
779
+ demands.delete(demand.options)
780
+ try {
781
+ demand.cleanup?.()
782
+ } catch (error) {
783
+ database.logger.error(
784
+ `Could not clean up subset hook for ${viewName}`,
785
+ error,
786
+ )
691
787
  }
788
+ }
692
789
 
693
- // Evict rows that were exclusively loaded by the departing predicate.
694
- // These are rows matching the departing WHERE that are no longer covered
695
- // by any remaining active predicate.
790
+ const performPhysicalRelease = async (
791
+ options: LoadSubsetOptions,
792
+ ): Promise<void> => {
696
793
  const compiledDeparting = compileSQLite({ where: options.where })
697
794
  const departingWhereSQL = toInlinedWhereClause(compiledDeparting)
795
+ let rowsToEvict: Array<{ id: string }>
796
+ for (;;) {
797
+ if (stopped) return
798
+ const revision = trackingRevision
799
+ const active = activeWhereExpressions()
800
+ let evictionSQL: string
801
+ if (active.length === 0) {
802
+ evictionSQL = `SELECT id FROM ${viewName} WHERE ${departingWhereSQL}`
803
+ } else {
804
+ const combinedRemaining =
805
+ active.length === 1
806
+ ? active[0]!
807
+ : or(active[0], active[1], ...active.slice(2))
808
+ const compiledRemaining = compileSQLite({
809
+ where: combinedRemaining,
810
+ })
811
+ const remainingWhereSQL = toInlinedWhereClause(compiledRemaining)
812
+ evictionSQL = `SELECT id FROM ${viewName} WHERE (${departingWhereSQL}) AND NOT (${remainingWhereSQL})`
813
+ }
698
814
 
699
- let evictionSQL: string
700
- if (activeWhereExpressions.length === 0) {
701
- evictionSQL = `SELECT id FROM ${viewName} WHERE ${departingWhereSQL}`
702
- } else {
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})`
815
+ rowsToEvict = await database.getAll<{ id: string }>(evictionSQL)
816
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cleanup can run during the query
817
+ if (stopped) return
818
+ if (trackingRevision === revision) break
716
819
  }
717
-
718
- const rowsToEvict = await database.getAll<{ id: string }>(evictionSQL)
719
820
  if (rowsToEvict.length > 0) {
720
821
  begin()
721
822
  for (const { id } of rowsToEvict) {
722
823
  write({ type: `delete`, key: id })
723
824
  }
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
825
  void commit()
729
826
  }
827
+ await rebuildTracking()
828
+ }
829
+
830
+ function scheduleReleaseDrain(delay = 0): void {
831
+ if (stopped || drainingReleases || releaseRetryTimer) return
832
+ if (delay > 0) {
833
+ releaseRetryTimer = setTimeout(() => {
834
+ releaseRetryTimer = undefined
835
+ void drainReleases()
836
+ }, delay)
837
+ return
838
+ }
839
+ void drainReleases()
840
+ }
841
+
842
+ async function drainReleases(): Promise<void> {
843
+ if (stopped || drainingReleases) return
844
+ drainingReleases = true
845
+ let retryDelay = 0
846
+ try {
847
+ const attempts = pendingReleases.length
848
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- each release can reenter cleanup
849
+ for (let index = 0; !stopped && index < attempts; index++) {
850
+ const pending = pendingReleases.shift()!
851
+ try {
852
+ await performPhysicalRelease(pending.options)
853
+ } catch (error) {
854
+ pending.failures++
855
+ pendingReleases.push(pending)
856
+ const delay = Math.min(
857
+ 1000 * 2 ** (pending.failures - 1),
858
+ 30000,
859
+ )
860
+ retryDelay =
861
+ retryDelay === 0 ? delay : Math.min(retryDelay, delay)
862
+ database.logger.error(
863
+ `Could not release subset tracking for ${viewName}; retrying`,
864
+ error,
865
+ )
866
+ }
867
+ }
868
+ } finally {
869
+ drainingReleases = false
870
+ }
871
+ if (pendingReleases.length > 0) scheduleReleaseDrain(retryDelay)
872
+ }
730
873
 
731
- // Recreate the diff trigger for the remaining active WHERE expressions.
732
- await loadSubset()
874
+ const unloadSubset = (options: LoadSubsetOptions): void => {
875
+ releasedSubsets.add(options)
876
+ const demand = demands.get(options)
877
+ if (!demand) return
878
+
879
+ const wasActive = demand.active
880
+ if (wasActive) trackingRevision++
881
+ cleanupDemand(demand)
882
+
883
+ if (wasActive) {
884
+ pendingReleases.push({ options, failures: 0 })
885
+ // New work must not wait for another release's backoff.
886
+ clearTimeout(releaseRetryTimer)
887
+ releaseRetryTimer = undefined
888
+ scheduleReleaseDrain()
889
+ }
733
890
  }
734
891
 
735
892
  markReady()
@@ -737,16 +894,19 @@ function createPowerSyncCollectionConfig<
737
894
  return {
738
895
  cleanup: () => {
739
896
  stopped = true
897
+ clearTimeout(releaseRetryTimer)
898
+ releaseRetryTimer = undefined
740
899
  database.logger.info(
741
900
  `Sync has been stopped for ${viewName} into ${trackedTableName}`,
742
901
  )
743
902
  abortController.abort()
744
- for (const cleanup of unloadSubsetCallbacks.values()) cleanup()
745
- unloadSubsetCallbacks.clear()
746
- activeWhereExpressions.length = 0
903
+ for (const demand of demands.values()) {
904
+ cleanupDemand(demand)
905
+ }
906
+ pendingReleases.length = 0
747
907
  },
748
908
  loadSubset: (options: LoadSubsetOptions) => loadSubset(options),
749
- unloadSubset: (options: LoadSubsetOptions) => unloadSubset(options),
909
+ unloadSubset,
750
910
  }
751
911
  }
752
912
  },