@tanstack/db-sqlite-persistence-core 0.4.8 → 0.4.9

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/persisted.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  SYNC_PERSISTENCE_PROTOCOL,
6
6
  SYNC_PERSISTENCE_VERSION,
7
7
  SyncTransactionAbortedError,
8
+ collectionOptionsClaim,
8
9
  compileSingleRowExpression,
9
10
  equalPersistedSnapshotValues,
10
11
  getLoadSubsetDemandKey,
@@ -354,6 +355,97 @@ export type PersistedRowScanOptions = SyncPersistenceScanOptions
354
355
 
355
356
  export type PersistedKeySetEvidence = SyncPersistenceKeySetEvidence
356
357
 
358
+ export type PersistedCacheGenerationClaim = {
359
+ storageCollectionId: string
360
+ claimId: string
361
+ /** Present when the adapter expires dormant claims. */
362
+ expiresAtMs?: number
363
+ }
364
+
365
+ /** An eager Collection name or a claim-bound persisted cache generation. */
366
+ export type PersistedStorageTarget =
367
+ | { kind: `eager`; collectionId: string }
368
+ | {
369
+ kind: `managed`
370
+ storageCollectionId: string
371
+ claimId: string
372
+ }
373
+
374
+ export function eagerStorageTarget(
375
+ collectionId: string,
376
+ ): PersistedStorageTarget {
377
+ return { kind: `eager`, collectionId }
378
+ }
379
+
380
+ export function managedStorageTarget(
381
+ storageCollectionId: string,
382
+ claimId: string,
383
+ ): PersistedStorageTarget {
384
+ return { kind: `managed`, storageCollectionId, claimId }
385
+ }
386
+
387
+ export function resolvePersistedStorageTarget(
388
+ target: PersistedStorageTarget,
389
+ legacyClaimId?: string,
390
+ ): { collectionId: string; claimId?: string } {
391
+ const input: unknown = target
392
+ if (typeof input !== `object` || input === null) {
393
+ throw new InvalidPersistedCollectionConfigError(
394
+ `SQLite persistence requires an explicit eager or managed storage target`,
395
+ )
396
+ }
397
+ const fields = input as Record<string, unknown>
398
+ if (fields.kind === `eager`) {
399
+ if (
400
+ typeof fields.collectionId !== `string` ||
401
+ fields.collectionId.trim().length === 0
402
+ ) {
403
+ throw new InvalidPersistedCollectionConfigError(
404
+ `An eager storage target requires a nonempty collectionId`,
405
+ )
406
+ }
407
+ if (
408
+ legacyClaimId !== undefined ||
409
+ `claimId` in fields ||
410
+ `storageCollectionId` in fields
411
+ ) {
412
+ throw new InvalidPersistedCollectionConfigError(
413
+ `An eager storage target cannot carry a persisted cache claim`,
414
+ )
415
+ }
416
+ return { collectionId: fields.collectionId }
417
+ }
418
+ if (fields.kind !== `managed`) {
419
+ throw new InvalidPersistedCollectionConfigError(
420
+ `Unknown SQLite persistence storage target kind`,
421
+ )
422
+ }
423
+ if (
424
+ typeof fields.storageCollectionId !== `string` ||
425
+ fields.storageCollectionId.trim().length === 0 ||
426
+ typeof fields.claimId !== `string` ||
427
+ fields.claimId.trim().length === 0
428
+ ) {
429
+ throw new InvalidPersistedCollectionConfigError(
430
+ `A managed storage target requires a storageCollectionId and persisted cache claim`,
431
+ )
432
+ }
433
+ if (`collectionId` in fields) {
434
+ throw new InvalidPersistedCollectionConfigError(
435
+ `A managed storage target cannot carry an eager collectionId`,
436
+ )
437
+ }
438
+ if (legacyClaimId !== undefined && legacyClaimId !== fields.claimId) {
439
+ throw new InvalidPersistedCollectionConfigError(
440
+ `The persisted cache claim does not match its storage target`,
441
+ )
442
+ }
443
+ return {
444
+ collectionId: fields.storageCollectionId,
445
+ claimId: fields.claimId,
446
+ }
447
+ }
448
+
357
449
  type PersistedResumeGeneration = {
358
450
  latestTerm: number
359
451
  latestSeq: number
@@ -384,6 +476,8 @@ export type PersistedTx<
384
476
  term: number
385
477
  seq: number
386
478
  rowVersion: number
479
+ /** Claim authorizing this transaction's persisted cache generation. */
480
+ cacheGenerationClaimId?: string
387
481
  truncate?: boolean
388
482
  mutations: Array<
389
483
  | {
@@ -423,9 +517,12 @@ export const SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY = Symbol.for(
423
517
 
424
518
  export interface PersistenceAdapter {
425
519
  loadSubset: (
426
- collectionId: string,
520
+ target: PersistedStorageTarget,
427
521
  options: LoadSubsetOptions,
428
- ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
522
+ ctx?: {
523
+ requiredIndexSignatures?: ReadonlyArray<string>
524
+ cacheGenerationClaimId?: string
525
+ },
429
526
  ) => Promise<
430
527
  Array<{
431
528
  key: string | number
@@ -434,10 +531,11 @@ export interface PersistenceAdapter {
434
531
  }>
435
532
  >
436
533
  loadResumeSnapshot: (
437
- collectionId: string,
534
+ target: PersistedStorageTarget,
438
535
  ctx?: {
439
536
  requiredIndexSignatures?: ReadonlyArray<string>
440
537
  includeRows?: boolean
538
+ cacheGenerationClaimId?: string
441
539
  },
442
540
  ) => Promise<{
443
541
  rows: Array<{
@@ -452,35 +550,64 @@ export interface PersistenceAdapter {
452
550
  latestRowVersion: number
453
551
  resetEpoch: number
454
552
  }>
455
- applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>
553
+ applyCommittedTx: (
554
+ target: PersistedStorageTarget,
555
+ tx: PersistedTx,
556
+ ) => Promise<void>
557
+ claimCacheGeneration?: (
558
+ collectionId: string,
559
+ ) => Promise<PersistedCacheGenerationClaim>
560
+ rotateCacheGeneration?: (
561
+ collectionId: string,
562
+ claimId: string,
563
+ resetMetadata?: { key: string; value: unknown },
564
+ expectedStorageCollectionId?: string,
565
+ ) => Promise<PersistedCacheGenerationClaim>
566
+ releaseCacheGenerationClaim?: (claimId: string) => Promise<void>
567
+ renewCacheGenerationClaim?: (
568
+ storageCollectionId: string,
569
+ claimId: string,
570
+ ) => Promise<number | undefined>
571
+ getCacheGenerationNow?: () => number
456
572
  /** Atomically certify or apply one source transaction after a lost response. */
457
573
  reconcileCommittedTx?: (
458
- collectionId: string,
574
+ target: PersistedStorageTarget,
459
575
  tx: PersistedTx,
460
576
  anchor: CommittedTxAnchor,
461
577
  ) => Promise<ReconciledCommittedTx>
462
578
  loadCollectionMetadata?: (
463
- collectionId: string,
579
+ target: PersistedStorageTarget,
580
+ ctx?: { cacheGenerationClaimId?: string },
464
581
  ) => Promise<Array<{ key: string; value: unknown }>>
465
582
  scanRows?: (
466
- collectionId: string,
583
+ target: PersistedStorageTarget,
467
584
  options?: PersistedRowScanOptions,
585
+ ctx?: { cacheGenerationClaimId?: string },
468
586
  ) => Promise<Array<PersistedScannedRow>>
469
587
  ensureIndex: (
470
- collectionId: string,
588
+ target: PersistedStorageTarget,
471
589
  signature: string,
472
590
  spec: PersistedIndexSpec,
591
+ ctx?: { cacheGenerationClaimId?: string },
473
592
  ) => Promise<void>
474
- markIndexRemoved?: (collectionId: string, signature: string) => Promise<void>
475
- getStreamPosition?: (collectionId: string) => Promise<{
593
+ markIndexRemoved?: (
594
+ target: PersistedStorageTarget,
595
+ signature: string,
596
+ ctx?: { cacheGenerationClaimId?: string },
597
+ ) => Promise<void>
598
+ getStreamPosition?: (
599
+ target: PersistedStorageTarget,
600
+ ctx?: { cacheGenerationClaimId?: string },
601
+ ) => Promise<{
476
602
  latestTerm: number
477
603
  latestSeq: number
478
604
  latestRowVersion: number
479
605
  }>
480
606
  /** Reserve a new durable election term before announcing Browser leadership. */
481
607
  reserveLeadershipTerm?: (
482
- collectionId: string,
608
+ target: PersistedStorageTarget,
483
609
  observedTerm: number,
610
+ ctx?: { cacheGenerationClaimId?: string },
484
611
  ) => Promise<{
485
612
  latestTerm: number
486
613
  latestSeq: number
@@ -504,8 +631,9 @@ export interface PersistenceAdapter {
504
631
 
505
632
  export type HydrationPersistenceAdapter = PersistenceAdapter & {
506
633
  pullSince?: (
507
- collectionId: string,
634
+ target: PersistedStorageTarget,
508
635
  fromRowVersion: number,
636
+ ctx?: { cacheGenerationClaimId?: string },
509
637
  ) => Promise<PersistencePullSinceResult>
510
638
  }
511
639
 
@@ -558,6 +686,7 @@ export interface PersistedCollectionCoordinator {
558
686
  setAdapterForCollection?: (
559
687
  collectionId: string,
560
688
  adapter: PersistenceAdapter,
689
+ cacheGenerationClaimId?: string,
561
690
  ) => void
562
691
  subscribe: (
563
692
  collectionId: string,
@@ -589,10 +718,12 @@ export interface PersistedCollectionCoordinator {
589
718
  spec: PersistedIndexSpec,
590
719
  scopedAdapter?: HydrationPersistenceAdapter,
591
720
  localEnsureCompleted?: boolean,
721
+ cacheGenerationClaimId?: string,
592
722
  ) => Promise<void>
593
723
  requestApplyLocalMutations?: (
594
724
  collectionId: string,
595
725
  mutations: Array<PersistedMutationEnvelope>,
726
+ cacheGenerationClaimId?: string,
596
727
  ) => Promise<ApplyLocalMutationsResponse>
597
728
  /**
598
729
  * Applies a committed transaction through the supplied adapter when present.
@@ -617,6 +748,7 @@ export interface PersistedCollectionCoordinator {
617
748
  collectionId: string,
618
749
  fromRowVersion: number,
619
750
  scopedAdapter?: HydrationPersistenceAdapter,
751
+ cacheGenerationClaimId?: string,
620
752
  ) => Promise<PullSinceResponse>
621
753
  }
622
754
 
@@ -694,6 +826,7 @@ export type PersistedInitialRenderOptions = {
694
826
  }
695
827
 
696
828
  const DEFAULT_NETWORK_TIMEOUT_MS = 3_000
829
+ const CACHE_CLAIM_RENEW_WINDOW_MS = 60_000
697
830
  const MAX_TIMER_TIMEOUT_MS = 2_147_483_647
698
831
 
699
832
  function getNetworkTimeoutMs(
@@ -813,7 +946,7 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
813
946
  ) => void)
814
947
  | null
815
948
  commit: ((signal?: AbortSignal) => SyncAppliedReceipt) | null
816
- truncate: (() => void) | null
949
+ truncate: ((options?: { markReady?: boolean }) => void) | null
817
950
  metadata: SyncMetadataApi<TKey> | null
818
951
  markError: ((error: unknown) => void) | null
819
952
  }
@@ -905,11 +1038,8 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
905
1038
  if (acquisitions!.size === 0) {
906
1039
  this.remoteSubsetAcquisitions.delete(collectionId)
907
1040
  }
908
- try {
909
- await acquisition.load
910
- } catch {
911
- // Calling the owner transferred the lease even when its load rejected.
912
- }
1041
+ // The owner may need unload to settle its load. The call itself already
1042
+ // transferred the lease, so waiting for load here can deadlock recovery.
913
1043
  await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options)
914
1044
  }
915
1045
 
@@ -927,14 +1057,9 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
927
1057
  this.remoteSubsetAcquisitions.delete(collectionId)
928
1058
  this.remoteSubsetOwners.delete(collectionId)
929
1059
  for (const acquisition of acquisitions?.values() ?? []) {
930
- void (async () => {
931
- try {
932
- await acquisition.load
933
- } catch {
934
- // Calling the owner transferred the lease even when its load rejected.
935
- }
936
- await unloadRemoteSubsetOwner(owner, acquisition.options)
937
- })().catch(() => undefined)
1060
+ void unloadRemoteSubsetOwner(owner, acquisition.options).catch(
1061
+ () => undefined,
1062
+ )
938
1063
  }
939
1064
  }
940
1065
  }
@@ -961,7 +1086,12 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
961
1086
  }
962
1087
 
963
1088
  try {
964
- await adapter.applyCommittedTx(collectionId, tx)
1089
+ await adapter.applyCommittedTx(
1090
+ tx.cacheGenerationClaimId
1091
+ ? managedStorageTarget(collectionId, tx.cacheGenerationClaimId)
1092
+ : eagerStorageTarget(collectionId),
1093
+ tx,
1094
+ )
965
1095
  } catch (error) {
966
1096
  throw toPersistedCollectionDurabilityError(collectionId, error)
967
1097
  }
@@ -1112,6 +1242,7 @@ type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
1112
1242
  truncate: boolean
1113
1243
  internal: boolean
1114
1244
  lifecycleGeneration: number
1245
+ admittedStorageCollectionId?: string
1115
1246
  expectedResumeGenerationOwner?: symbol
1116
1247
  signal?: AbortSignal
1117
1248
  prependHydrationRows: (
@@ -1199,6 +1330,21 @@ class ApplyMutex {
1199
1330
  }
1200
1331
  }
1201
1332
 
1333
+ function matchesAnySubset(
1334
+ value: object,
1335
+ evaluators: ReadonlyArray<ReturnType<
1336
+ typeof compileSingleRowExpression
1337
+ > | null>,
1338
+ ): boolean {
1339
+ return evaluators.some(
1340
+ (evaluator) =>
1341
+ !evaluator ||
1342
+ toBooleanPredicate(
1343
+ evaluator(value as Record<string, unknown>) as boolean | null,
1344
+ ),
1345
+ )
1346
+ }
1347
+
1202
1348
  function toStableSerializable(value: unknown): unknown {
1203
1349
  if (value == null) {
1204
1350
  return value
@@ -1369,18 +1515,36 @@ class PersistedCollectionRuntime<
1369
1515
  string,
1370
1516
  LoadSubsetOptions
1371
1517
  >()
1518
+ private readonly scopedRefreshes = new Map<LoadSubsetOptions, string>()
1519
+ /** A retained demand may use fresh physical request data after rotation. */
1520
+ private readonly rotatedAcquisitions = new Map<
1521
+ LoadSubsetOptions,
1522
+ LoadSubsetOptions
1523
+ >()
1524
+ /** An in-flight load waits for the recovery acquisition of its demand. */
1525
+ private readonly rotatedEnsures = new WeakMap<
1526
+ LoadSubsetOptions,
1527
+ Promise<void>
1528
+ >()
1372
1529
  private readonly queuedHydrationTransactions: Array<
1373
1530
  BufferedSyncTransaction<T, TKey>
1374
1531
  > = []
1375
1532
  private readonly queuedTxCommitted: Array<{
1376
1533
  txCommitted: TxCommitted
1377
1534
  lifecycleGeneration: number
1535
+ storageCollectionId: string
1378
1536
  }> = []
1379
1537
  private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
1380
1538
  private readonly hydratedDemands = new Set<string | undefined>()
1381
1539
 
1382
1540
  private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
1383
1541
  null
1542
+ // Cleanup ends the active sync run, but cannot transfer this runtime's owner.
1543
+ private boundCollection: Collection<
1544
+ T,
1545
+ TKey,
1546
+ PersistedCollectionUtils
1547
+ > | null = null
1384
1548
  private syncControls: SyncControlFns<T, TKey> = {
1385
1549
  begin: null,
1386
1550
  write: null,
@@ -1411,9 +1575,22 @@ class PersistedCollectionRuntime<
1411
1575
  private indexAddedUnsubscribe: (() => void) | null = null
1412
1576
  private indexRemovedUnsubscribe: (() => void) | null = null
1413
1577
  private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
1578
+ private cacheClaimRenewTimer: ReturnType<typeof setTimeout> | null = null
1579
+ private cacheClaimRecovery: Promise<void> | null = null
1414
1580
  private nextRequestId = 0
1415
1581
  private startupSettled = false
1416
1582
  private sourceTruncateGeneration = 0
1583
+ /** Durable rows cannot replace source rows during scoped recovery. */
1584
+ private scopedRecovery = false
1585
+ private scopedRecoveryPending = false
1586
+ private scopedRecoverySequence = 0
1587
+ private recoverQueuedAfterLocalLoadFailure = false
1588
+ private cacheGenerationClaim: PersistedCacheGenerationClaim | undefined
1589
+ private startupCacheGenerationRotated = false
1590
+ private remoteSubsetOwner: RemoteSubsetOwner | undefined
1591
+ // Undefined before source assignment; null means the source cannot restart.
1592
+ private restartSourceAfterScopedRecovery:
1593
+ ((cacheRotated: Promise<void>) => void | Promise<void>) | null | undefined
1417
1594
 
1418
1595
  private latestTerm = 0
1419
1596
  private latestSeq = 0
@@ -1435,8 +1612,234 @@ class PersistedCollectionRuntime<
1435
1612
  private readonly syncMode: `eager` | `on-demand`,
1436
1613
  private readonly dbName: string,
1437
1614
  private readonly persistedReadiness?: PersistedReadinessTracker,
1615
+ private readonly optionsClaim?: () => void | (() => void),
1438
1616
  ) {}
1439
1617
 
1618
+ private get storageCollectionId(): string {
1619
+ return this.cacheGenerationClaim?.storageCollectionId ?? this.collectionId
1620
+ }
1621
+
1622
+ private storageTargetFor(
1623
+ storageCollectionId: string = this.storageCollectionId,
1624
+ claimId: string | undefined = this.cacheGenerationClaim?.claimId,
1625
+ ): PersistedStorageTarget {
1626
+ if (
1627
+ this.mode === `sync-present` &&
1628
+ this.syncMode === `on-demand` &&
1629
+ this.persistence.adapter.claimCacheGeneration
1630
+ ) {
1631
+ if (!claimId) {
1632
+ throw new InvalidPersistedCollectionConfigError(
1633
+ `A managed on-demand cache operation requires a persisted cache claim`,
1634
+ )
1635
+ }
1636
+ return { kind: `managed`, storageCollectionId, claimId }
1637
+ }
1638
+ return { kind: `eager`, collectionId: storageCollectionId }
1639
+ }
1640
+
1641
+ private cacheGenerationNow(): number {
1642
+ return this.persistence.adapter.getCacheGenerationNow?.() ?? Date.now()
1643
+ }
1644
+
1645
+ private hasExpiredCacheClaim(): boolean {
1646
+ const expiresAtMs = this.cacheGenerationClaim?.expiresAtMs
1647
+ return expiresAtMs !== undefined && this.cacheGenerationNow() >= expiresAtMs
1648
+ }
1649
+
1650
+ private async claimCacheGeneration(
1651
+ lifecycleGeneration: number,
1652
+ ): Promise<void> {
1653
+ if (this.mode !== `sync-present` || this.syncMode !== `on-demand`) return
1654
+ const adapter = this.persistence.adapter
1655
+ const capabilities = [
1656
+ adapter.claimCacheGeneration,
1657
+ adapter.rotateCacheGeneration,
1658
+ adapter.renewCacheGenerationClaim,
1659
+ adapter.releaseCacheGenerationClaim,
1660
+ ]
1661
+ if (capabilities.some(Boolean) && !capabilities.every(Boolean)) {
1662
+ throw new InvalidPersistedCollectionConfigError(
1663
+ `An on-demand managed cache generation adapter must implement claimCacheGeneration, rotateCacheGeneration, renewCacheGenerationClaim, and releaseCacheGenerationClaim together`,
1664
+ )
1665
+ }
1666
+ if (!adapter.claimCacheGeneration) return
1667
+ const claim = await adapter.claimCacheGeneration(this.collectionId)
1668
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1669
+ await adapter.releaseCacheGenerationClaim?.(claim.claimId)
1670
+ return
1671
+ }
1672
+ this.cacheGenerationClaim = claim
1673
+ this.scheduleCacheClaimRenewal(claim)
1674
+ this.attachCoordinatorSubscription()
1675
+ }
1676
+
1677
+ private clearCacheClaimRenewal(): void {
1678
+ if (this.cacheClaimRenewTimer !== null) {
1679
+ clearTimeout(this.cacheClaimRenewTimer)
1680
+ this.cacheClaimRenewTimer = null
1681
+ }
1682
+ }
1683
+
1684
+ private scheduleCacheClaimRenewal(
1685
+ claim: PersistedCacheGenerationClaim,
1686
+ ): void {
1687
+ this.clearCacheClaimRenewal()
1688
+ if (
1689
+ claim.expiresAtMs === undefined ||
1690
+ !this.persistence.adapter.renewCacheGenerationClaim
1691
+ ) {
1692
+ return
1693
+ }
1694
+ const remaining = claim.expiresAtMs - this.cacheGenerationNow()
1695
+ const delay =
1696
+ remaining <= 0
1697
+ ? 1_000
1698
+ : Math.max(
1699
+ 1,
1700
+ Math.min(CACHE_CLAIM_RENEW_WINDOW_MS, Math.floor(remaining / 2)),
1701
+ )
1702
+ const lifecycleGeneration = this.lifecycleGeneration
1703
+ this.cacheClaimRenewTimer = setTimeout(() => {
1704
+ this.cacheClaimRenewTimer = null
1705
+ void this.ensureCacheClaimActive().catch(() => {
1706
+ // A failed renewal does not establish claim loss. Adapter reads and
1707
+ // writes still check the claim; retry the background check without
1708
+ // turning one transient storage error into a permanent Collection
1709
+ // failure. An expired claim retries at a bounded pace.
1710
+ if (
1711
+ lifecycleGeneration === this.lifecycleGeneration &&
1712
+ this.cacheGenerationClaim === claim
1713
+ ) {
1714
+ this.scheduleCacheClaimRenewal(claim)
1715
+ }
1716
+ })
1717
+ }, delay)
1718
+ }
1719
+
1720
+ private async ensureCacheClaimActive(): Promise<void> {
1721
+ if (this.cacheClaimRecovery) {
1722
+ await this.cacheClaimRecovery
1723
+ return
1724
+ }
1725
+ if (await this.renewCacheClaim(this.persistence.adapter)) return
1726
+ // Another caller may have started recovery while renewal was awaited.
1727
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1728
+ if (!this.cacheClaimRecovery) {
1729
+ const recovery =
1730
+ this.restartSourceAfterScopedRecovery === undefined
1731
+ ? this.reclaimStartupCacheGeneration()
1732
+ : this.startScopedRecovery()
1733
+ this.cacheClaimRecovery = recovery
1734
+ void recovery
1735
+ .finally(() => {
1736
+ if (this.cacheClaimRecovery === recovery) {
1737
+ this.cacheClaimRecovery = null
1738
+ }
1739
+ })
1740
+ .catch(() => undefined)
1741
+ }
1742
+ await this.cacheClaimRecovery
1743
+ }
1744
+
1745
+ private scheduleCacheClaimRecovery(lifecycleGeneration: number): void {
1746
+ // Recovery may need the apply mutex held by the caller. Let that caller
1747
+ // release its turn before checking the claim and rotating the cache.
1748
+ queueMicrotask(() => {
1749
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1750
+ void this.ensureCacheClaimActive()
1751
+ .then(() =>
1752
+ this.applyMutex.run(() => this.flushQueuedTxCommittedUnsafe()),
1753
+ )
1754
+ .catch((error) => {
1755
+ this.markTerminalFailure(error, lifecycleGeneration)
1756
+ })
1757
+ })
1758
+ }
1759
+
1760
+ private async reclaimStartupCacheGeneration(): Promise<void> {
1761
+ const lifecycleGeneration = this.lifecycleGeneration
1762
+ const previous = this.cacheGenerationClaim
1763
+ if (!previous) return
1764
+ if (!this.persistence.adapter.rotateCacheGeneration) {
1765
+ throw new InvalidPersistedCollectionConfigError(
1766
+ `A managed on-demand cache adapter must rotate expired startup claims`,
1767
+ )
1768
+ }
1769
+ this.clearCacheClaimRenewal()
1770
+ this.coordinatorUnsubscribe?.()
1771
+ this.coordinatorUnsubscribe = null
1772
+ const rotated = await this.persistence.adapter.rotateCacheGeneration(
1773
+ this.collectionId,
1774
+ previous.claimId,
1775
+ undefined,
1776
+ previous.storageCollectionId,
1777
+ )
1778
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1779
+ await this.persistence.adapter.releaseCacheGenerationClaim?.(
1780
+ rotated.claimId,
1781
+ )
1782
+ return
1783
+ }
1784
+ this.cacheGenerationClaim = rotated
1785
+ this.startupCacheGenerationRotated = true
1786
+ this.resetSequence++
1787
+ this.latestTerm = 0
1788
+ this.latestSeq = 0
1789
+ this.latestRowVersion = 0
1790
+ this.localTerm = 1
1791
+ this.localSeq = 0
1792
+ this.localRowVersion = 0
1793
+ this.persistedResumeGeneration = undefined
1794
+ this.persistedKeySetEvidence = { status: `incompatible` }
1795
+ this.scheduleCacheClaimRenewal(rotated)
1796
+ this.attachCoordinatorSubscription()
1797
+ }
1798
+
1799
+ private async renewCacheClaim(
1800
+ adapter: HydrationPersistenceAdapter,
1801
+ ): Promise<boolean> {
1802
+ const claim = this.cacheGenerationClaim
1803
+ if (
1804
+ !claim ||
1805
+ !adapter.renewCacheGenerationClaim ||
1806
+ this.scopedRecoveryPending
1807
+ )
1808
+ return true
1809
+ if (
1810
+ claim.expiresAtMs !== undefined &&
1811
+ claim.expiresAtMs - this.cacheGenerationNow() >
1812
+ CACHE_CLAIM_RENEW_WINDOW_MS
1813
+ ) {
1814
+ // Durable operations check their claim at the adapter boundary. Keep
1815
+ // the background timer armed without writing the same live claim for
1816
+ // every foreground operation.
1817
+ if (this.cacheClaimRenewTimer === null) {
1818
+ this.scheduleCacheClaimRenewal(claim)
1819
+ }
1820
+ return true
1821
+ }
1822
+ const lifecycleGeneration = this.lifecycleGeneration
1823
+ const expiresAtMs = await adapter.renewCacheGenerationClaim(
1824
+ claim.storageCollectionId,
1825
+ claim.claimId,
1826
+ )
1827
+ if (
1828
+ lifecycleGeneration !== this.lifecycleGeneration ||
1829
+ this.cacheGenerationClaim?.claimId !== claim.claimId ||
1830
+ this.cacheGenerationClaim.storageCollectionId !==
1831
+ claim.storageCollectionId
1832
+ ) {
1833
+ return true
1834
+ }
1835
+ if (expiresAtMs === undefined) {
1836
+ return false
1837
+ }
1838
+ this.cacheGenerationClaim = { ...claim, expiresAtMs }
1839
+ this.scheduleCacheClaimRenewal(this.cacheGenerationClaim)
1840
+ return true
1841
+ }
1842
+
1440
1843
  setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
1441
1844
  this.advanceLifecycle()
1442
1845
  this.syncErrorReported = false
@@ -1451,6 +1854,17 @@ class PersistedCollectionRuntime<
1451
1854
  }
1452
1855
  }
1453
1856
 
1857
+ setSourceRestartAfterScopedRecovery(
1858
+ restart:
1859
+ ((cacheRotated: Promise<void>) => void | Promise<void>) | undefined,
1860
+ ): void {
1861
+ this.restartSourceAfterScopedRecovery = restart ?? null
1862
+ }
1863
+
1864
+ clearSourceRestartAfterScopedRecovery(): void {
1865
+ this.restartSourceAfterScopedRecovery = undefined
1866
+ }
1867
+
1454
1868
  reportSyncError(error: unknown): unknown {
1455
1869
  const markError = this.syncControls.markError
1456
1870
  if (this.syncErrorReported) return this.reportedSyncError
@@ -1468,9 +1882,10 @@ class PersistedCollectionRuntime<
1468
1882
 
1469
1883
  registerRemoteSubsetOwner(owner: RemoteSubsetOwner): void {
1470
1884
  this.remoteSubsetOwnerUnsubscribe?.()
1885
+ this.remoteSubsetOwner = owner
1471
1886
  this.remoteSubsetOwnerUnsubscribe =
1472
1887
  this.persistence.coordinator.registerRemoteSubsetOwner(
1473
- this.collectionId,
1888
+ this.storageCollectionId,
1474
1889
  owner,
1475
1890
  )
1476
1891
  }
@@ -1504,6 +1919,31 @@ class PersistedCollectionRuntime<
1504
1919
  )
1505
1920
  }
1506
1921
 
1922
+ // An ensure reply settles a follower's load (law 7). A source's loadSubset
1923
+ // must await every commit receipt that establishes its result, so the
1924
+ // leader broadcasts those commits before it replies on the same channel.
1925
+ // They are already queued on the apply mutex; settle after they show here.
1926
+ private async applyLeaderCommitsBeforeReply(
1927
+ options: LoadSubsetOptions,
1928
+ ): Promise<void> {
1929
+ try {
1930
+ // Commits kept while the claim was expired apply after it is active.
1931
+ if (this.hasExpiredCacheClaim() || this.cacheClaimRecovery) {
1932
+ await this.ensureCacheClaimActive()
1933
+ }
1934
+ await this.applyMutex.run(() => this.flushQueuedTxCommittedUnsafe())
1935
+ // A broadcast that failed to apply made the runtime terminal; its rows
1936
+ // are not visible, so the load must not settle successfully.
1937
+ this.throwIfTerminal()
1938
+ } catch (error) {
1939
+ // A terminal runtime releases the acquisition the owner granted the
1940
+ // load. Otherwise, as after a failed ensure, the demand retries.
1941
+ if (this.getCurrentTerminalFailure()) this.unloadSubset(options)
1942
+ else this.queueRemoteSubsetEnsure(options)
1943
+ throw error
1944
+ }
1945
+ }
1946
+
1507
1947
  private clearSyncControls(): void {
1508
1948
  this.syncControls = {
1509
1949
  begin: null,
@@ -1579,21 +2019,63 @@ class PersistedCollectionRuntime<
1579
2019
  return this.internalApplyDepth > 0
1580
2020
  }
1581
2021
 
2022
+ isScopedRecoveryPending(): boolean {
2023
+ return this.scopedRecoveryPending
2024
+ }
2025
+
2026
+ hasBoundCollection(): boolean {
2027
+ return this.boundCollection !== null
2028
+ }
2029
+
1582
2030
  setCollection(
1583
2031
  collection: Collection<T, TKey, PersistedCollectionUtils>,
2032
+ syncControls: SyncControlFns<T, TKey>,
1584
2033
  ): void {
2034
+ if (
2035
+ (
2036
+ collection.config as typeof collection.config & {
2037
+ [collectionOptionsClaim]?: unknown
2038
+ }
2039
+ )[collectionOptionsClaim] !== this.optionsClaim
2040
+ ) {
2041
+ throw new InvalidPersistedCollectionConfigError(
2042
+ `Persisted sync belongs to its options Collection`,
2043
+ )
2044
+ }
2045
+ if (this.boundCollection && this.boundCollection !== collection) {
2046
+ throw new InvalidPersistedCollectionConfigError(
2047
+ `Persisted options cannot own more than one Collection`,
2048
+ )
2049
+ }
2050
+
2051
+ const previousCollection = this.collection
2052
+ const previousControls = this.syncControls
2053
+ this.setSyncControls(syncControls)
1585
2054
  if (this.collection === collection) {
2055
+ this.boundCollection ??= collection
1586
2056
  return
1587
2057
  }
1588
-
1589
2058
  this.collection = collection
1590
- this.attachCoordinatorSubscription()
2059
+ try {
2060
+ if (
2061
+ !this.persistence.adapter.claimCacheGeneration ||
2062
+ this.mode !== `sync-present` ||
2063
+ this.syncMode !== `on-demand`
2064
+ ) {
2065
+ this.attachCoordinatorSubscription()
2066
+ }
2067
+ } catch (error) {
2068
+ this.collection = previousCollection
2069
+ this.syncControls = previousControls
2070
+ throw error
2071
+ }
2072
+ this.boundCollection ??= collection
1591
2073
  }
1592
2074
 
1593
2075
  getLeadershipState(): PersistedCollectionLeadershipState {
1594
2076
  return {
1595
2077
  nodeId: this.persistence.coordinator.getNodeId(),
1596
- isLeader: this.persistence.coordinator.isLeader(this.collectionId),
2078
+ isLeader: this.persistence.coordinator.isLeader(this.storageCollectionId),
1597
2079
  }
1598
2080
  }
1599
2081
 
@@ -1614,6 +2096,7 @@ class PersistedCollectionRuntime<
1614
2096
  }
1615
2097
 
1616
2098
  const lifecycleGeneration = this.lifecycleGeneration
2099
+ this.startupCacheGenerationRotated = false
1617
2100
  const isRestart = this.hasAttemptedStartup
1618
2101
  this.hasAttemptedStartup = true
1619
2102
  let resolveStartupMetadata!: () => void
@@ -1625,6 +2108,12 @@ class PersistedCollectionRuntime<
1625
2108
  void this.startupMetadataPromise.catch(() => undefined)
1626
2109
 
1627
2110
  this.startPromise = (async () => {
2111
+ await this.claimCacheGeneration(lifecycleGeneration)
2112
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
2113
+ resolveStartupMetadata()
2114
+ return
2115
+ }
2116
+ let metadataExpired = false
1628
2117
  const loadStartupMetadata = async (
1629
2118
  adapter: HydrationPersistenceAdapter,
1630
2119
  ) => {
@@ -1634,9 +2123,24 @@ class PersistedCollectionRuntime<
1634
2123
  }
1635
2124
 
1636
2125
  try {
1637
- await this.loadStartupMetadataInternal(lifecycleGeneration, adapter)
2126
+ if (
2127
+ !(await this.loadStartupMetadataInternal(
2128
+ lifecycleGeneration,
2129
+ adapter,
2130
+ ))
2131
+ ) {
2132
+ metadataExpired = true
2133
+ return false
2134
+ }
1638
2135
  return lifecycleGeneration === this.lifecycleGeneration
1639
2136
  } catch (error) {
2137
+ // SQLite can reject its second claim check after a startup read
2138
+ // entered with a live claim. That old cache cannot authorize source
2139
+ // entry; rotate it after the hydration scope releases its scheduler.
2140
+ if (this.cacheGenerationClaim && this.hasExpiredCacheClaim()) {
2141
+ metadataExpired = true
2142
+ return false
2143
+ }
1640
2144
  rejectStartupMetadata(error)
1641
2145
  throw error
1642
2146
  }
@@ -1653,50 +2157,82 @@ class PersistedCollectionRuntime<
1653
2157
  this.persistence.adapter.runInHydrationScope !== undefined &&
1654
2158
  (!isRestart ||
1655
2159
  (this.persistence.adapter.isHydrationScopeScheduled?.() ?? true))
1656
- if (scheduleStartupAsOneHydrate) {
1657
- startup = await this.applyMutex.run(async () => {
1658
- const result = await this.runInHydrationScope(async (adapter) => {
1659
- if (!(await loadStartupMetadata(adapter))) return undefined
1660
- return this.startInternal(
1661
- lifecycleGeneration,
1662
- adapter,
1663
- resolveStartupMetadata,
1664
- )
2160
+ for (;;) {
2161
+ metadataExpired = false
2162
+ const storageAtAttempt = this.storageCollectionId
2163
+ if (scheduleStartupAsOneHydrate) {
2164
+ startup = await this.applyMutex.run(async () => {
2165
+ const result = await this.runInHydrationScope(async (adapter) => {
2166
+ if (!(await loadStartupMetadata(adapter))) return undefined
2167
+ return this.startInternal(
2168
+ lifecycleGeneration,
2169
+ adapter,
2170
+ resolveStartupMetadata,
2171
+ )
2172
+ })
2173
+ if (lifecycleGeneration === this.lifecycleGeneration) {
2174
+ await this.flushQueuedTxCommittedUnsafe()
2175
+ }
2176
+ return result
1665
2177
  })
1666
- if (lifecycleGeneration === this.lifecycleGeneration) {
1667
- await this.flushQueuedTxCommittedUnsafe()
1668
- }
1669
- return result
1670
- })
1671
- } else {
1672
- // Preserve the existing unscheduled-adapter lifecycle contract: a
1673
- // replacement upstream may start while stale hydration is settling.
1674
- if (await loadStartupMetadata(this.persistence.adapter)) {
1675
- if (this.persistence.adapter.isHydrationScopeScheduled?.()) {
1676
- startup = await this.applyMutex.run(async () => {
1677
- const result = await this.runInHydrationScope((adapter) =>
1678
- this.startInternal(
1679
- lifecycleGeneration,
1680
- adapter,
1681
- resolveStartupMetadata,
1682
- ),
2178
+ } else {
2179
+ // Preserve the existing unscheduled-adapter lifecycle contract: a
2180
+ // replacement upstream may start while stale hydration is settling.
2181
+ if (await loadStartupMetadata(this.persistence.adapter)) {
2182
+ if (this.persistence.adapter.isHydrationScopeScheduled?.()) {
2183
+ startup = await this.applyMutex.run(async () => {
2184
+ const result = await this.runInHydrationScope((adapter) =>
2185
+ this.startInternal(
2186
+ lifecycleGeneration,
2187
+ adapter,
2188
+ resolveStartupMetadata,
2189
+ ),
2190
+ )
2191
+ if (lifecycleGeneration === this.lifecycleGeneration) {
2192
+ await this.flushQueuedTxCommittedUnsafe()
2193
+ }
2194
+ return result
2195
+ })
2196
+ } else {
2197
+ startup = await this.startInternal(
2198
+ lifecycleGeneration,
2199
+ this.persistence.adapter,
2200
+ resolveStartupMetadata,
1683
2201
  )
1684
2202
  if (lifecycleGeneration === this.lifecycleGeneration) {
1685
2203
  await this.flushQueuedTxCommittedUnsafe()
1686
2204
  }
1687
- return result
1688
- })
1689
- } else {
1690
- startup = await this.startInternal(
1691
- lifecycleGeneration,
1692
- this.persistence.adapter,
1693
- resolveStartupMetadata,
1694
- )
1695
- if (lifecycleGeneration === this.lifecycleGeneration) {
1696
- await this.flushQueuedTxCommittedUnsafe()
1697
2205
  }
1698
2206
  }
1699
2207
  }
2208
+ if (
2209
+ this.cacheGenerationClaim &&
2210
+ this.syncMode === `on-demand` &&
2211
+ // Hydration may have changed this through loadStartupMetadata().
2212
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2213
+ !metadataExpired
2214
+ ) {
2215
+ // Source entry must wait until any private rotation started during
2216
+ // index bootstrap has settled. The source has no restart capability
2217
+ // until its sync callback returns.
2218
+ await this.ensureCacheClaimActive()
2219
+ metadataExpired = storageAtAttempt !== this.storageCollectionId
2220
+ }
2221
+ if (!metadataExpired) break
2222
+ // A startup read cannot reacquire the claim inside its hydration
2223
+ // scope: claim acquisition uses the regular scheduler lane.
2224
+ await this.ensureCacheClaimActive()
2225
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
2226
+ resolveStartupMetadata()
2227
+ return
2228
+ }
2229
+ }
2230
+ if (
2231
+ this.cacheGenerationClaim &&
2232
+ this.syncMode === `on-demand` &&
2233
+ lifecycleGeneration === this.lifecycleGeneration
2234
+ ) {
2235
+ resolveStartupMetadata()
1700
2236
  }
1701
2237
  if (
1702
2238
  startup !== undefined &&
@@ -1740,6 +2276,7 @@ class PersistedCollectionRuntime<
1740
2276
  await this.ensureStarted()
1741
2277
  if (lifecycleGeneration !== this.lifecycleGeneration) return
1742
2278
  if (this.syncMode !== `on-demand`) return
2279
+ if (this.scopedRecovery) return
1743
2280
 
1744
2281
  const appliedCursor = await this.applyMutex.run(async () => {
1745
2282
  const result = await this.runInHydrationScope((adapter) =>
@@ -1750,6 +2287,11 @@ class PersistedCollectionRuntime<
1750
2287
  }
1751
2288
  return result
1752
2289
  })
2290
+ if (appliedCursor === false) {
2291
+ // Rotation needs the apply mutex, so recover only after releasing it.
2292
+ await this.ensureCacheClaimActive()
2293
+ return
2294
+ }
1753
2295
  if (
1754
2296
  appliedCursor !== undefined &&
1755
2297
  lifecycleGeneration === this.lifecycleGeneration
@@ -1771,15 +2313,39 @@ class PersistedCollectionRuntime<
1771
2313
  this.resumeCertificationPromise = (async () => {
1772
2314
  await this.ensureStarted()
1773
2315
  if (lifecycleGeneration !== this.lifecycleGeneration) return
1774
-
1775
- const snapshot = await this.persistence.adapter.loadResumeSnapshot(
1776
- this.collectionId,
1777
- {
1778
- requiredIndexSignatures: this.getRequiredIndexSignatures(),
1779
- includeRows: false,
1780
- },
1781
- )
1782
- if (lifecycleGeneration !== this.lifecycleGeneration) return
2316
+ if (this.scopedRecovery) return
2317
+
2318
+ const storageCollectionId = this.storageCollectionId
2319
+ const claimId = this.cacheGenerationClaim?.claimId
2320
+ const certificationIsCurrent = () =>
2321
+ lifecycleGeneration === this.lifecycleGeneration &&
2322
+ !this.scopedRecovery &&
2323
+ storageCollectionId === this.storageCollectionId &&
2324
+ claimId === this.cacheGenerationClaim?.claimId
2325
+ let snapshot: Awaited<
2326
+ ReturnType<PersistenceAdapter[`loadResumeSnapshot`]>
2327
+ >
2328
+ try {
2329
+ snapshot = await this.persistence.adapter.loadResumeSnapshot(
2330
+ this.storageTargetFor(storageCollectionId, claimId),
2331
+ {
2332
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
2333
+ includeRows: false,
2334
+ cacheGenerationClaimId: claimId,
2335
+ },
2336
+ )
2337
+ } catch (error) {
2338
+ if (!certificationIsCurrent()) return
2339
+ if (!claimId || !this.hasExpiredCacheClaim()) throw error
2340
+ await this.ensureCacheClaimActive()
2341
+ return
2342
+ }
2343
+ if (!certificationIsCurrent()) return
2344
+ if (claimId && !(await this.renewCacheClaim(this.persistence.adapter))) {
2345
+ await this.ensureCacheClaimActive()
2346
+ return
2347
+ }
2348
+ if (!certificationIsCurrent()) return
1783
2349
  this.bindResumeSnapshotEvidence(snapshot)
1784
2350
  })()
1785
2351
  return this.resumeCertificationPromise
@@ -1789,6 +2355,187 @@ class PersistedCollectionRuntime<
1789
2355
  return this.persistedKeySetEvidence
1790
2356
  }
1791
2357
 
2358
+ hasManagedCacheGeneration(): boolean {
2359
+ return this.cacheGenerationClaim !== undefined
2360
+ }
2361
+
2362
+ hasRotatedStartupCacheGeneration(): boolean {
2363
+ return this.startupCacheGenerationRotated
2364
+ }
2365
+
2366
+ async startScopedRecovery(resetMetadata?: {
2367
+ key: string
2368
+ value: unknown
2369
+ }): Promise<void> {
2370
+ this.throwIfTerminal()
2371
+ this.clearCacheClaimRenewal()
2372
+ const lifecycleGeneration = this.lifecycleGeneration
2373
+ const restartSource = this.restartSourceAfterScopedRecovery
2374
+ // Invalidate an in-flight old-cache read before yielding to the mutex.
2375
+ this.scopedRecovery = true
2376
+ this.scopedRecoveryPending = true
2377
+ const recoverySequence = ++this.scopedRecoverySequence
2378
+ this.hydratedDemands.clear()
2379
+ this.resetSequence++
2380
+ let resolveCacheRotated: (() => void) | undefined
2381
+ let rejectCacheRotated: ((error: unknown) => void) | undefined
2382
+ let sourceRestart: Promise<void> | undefined
2383
+ try {
2384
+ if (restartSource) {
2385
+ const cacheRotated = new Promise<void>((resolve, reject) => {
2386
+ resolveCacheRotated = resolve
2387
+ rejectCacheRotated = reject
2388
+ })
2389
+ void cacheRotated.catch(() => undefined)
2390
+ // The source must retire old callbacks before this call returns.
2391
+ sourceRestart = Promise.resolve(restartSource(cacheRotated))
2392
+ void sourceRestart.catch(() => undefined)
2393
+ }
2394
+ await this.applyMutex.run(async () => {
2395
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2396
+ const claim = this.cacheGenerationClaim
2397
+ if (claim && this.persistence.adapter.rotateCacheGeneration) {
2398
+ const previousStorageId = claim.storageCollectionId
2399
+ const rotated = await this.persistence.adapter.rotateCacheGeneration(
2400
+ this.collectionId,
2401
+ claim.claimId,
2402
+ resetMetadata,
2403
+ previousStorageId,
2404
+ )
2405
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
2406
+ // Cleanup released the old claim while rotation was in flight.
2407
+ // The returned claim belongs to this abandoned recovery too.
2408
+ await this.persistence.adapter.releaseCacheGenerationClaim?.(
2409
+ rotated.claimId,
2410
+ )
2411
+ throw new SyncTransactionAbortedError()
2412
+ }
2413
+ for (const options of this.activeSubsets.values()) {
2414
+ // The retired generation has a separate coordinator route. A
2415
+ // paused old leader cannot hold up the new cache's demands.
2416
+ void this.persistence.coordinator
2417
+ .requestReleaseRemoteSubset(
2418
+ previousStorageId,
2419
+ this.rotatedAcquisitions.get(options) ?? options,
2420
+ )
2421
+ .catch(() => undefined)
2422
+ if (options.signal?.aborted) {
2423
+ this.rotatedAcquisitions.delete(options)
2424
+ continue
2425
+ }
2426
+ this.rotatedAcquisitions.set(options, {
2427
+ ...options,
2428
+ refetch: true,
2429
+ })
2430
+ }
2431
+ this.remoteSubsetOwnerUnsubscribe?.()
2432
+ this.remoteSubsetOwnerUnsubscribe = null
2433
+ this.coordinatorUnsubscribe?.()
2434
+ this.coordinatorUnsubscribe = null
2435
+ this.cacheGenerationClaim = rotated
2436
+ this.latestTerm = 0
2437
+ this.latestSeq = 0
2438
+ this.latestRowVersion = 0
2439
+ this.publicRowVersion = 0
2440
+ this.resetTermFence = undefined
2441
+ this.localTerm = 1
2442
+ this.localSeq = 0
2443
+ this.localRowVersion = 0
2444
+ this.queuedTxCommitted.length = 0
2445
+ this.persistedResumeGeneration = undefined
2446
+ this.persistedKeySetEvidence = { status: `incompatible` }
2447
+ this.resumeCertificationPromise = null
2448
+ this.resumeGenerationOwner = Symbol(
2449
+ `persisted resume generation owner`,
2450
+ )
2451
+ this.attachCoordinatorSubscription()
2452
+ if (this.remoteSubsetOwner) {
2453
+ this.registerRemoteSubsetOwner(this.remoteSubsetOwner)
2454
+ }
2455
+ }
2456
+ if (this.syncControls.begin && this.syncControls.commit) {
2457
+ const applied = this.withInternalApply(() => {
2458
+ this.syncControls.begin?.()
2459
+ this.syncControls.truncate?.({ markReady: false })
2460
+ if (resetMetadata) {
2461
+ this.syncControls.metadata?.collection.set(
2462
+ resetMetadata.key,
2463
+ resetMetadata.value,
2464
+ )
2465
+ }
2466
+ return this.syncControls.commit?.() ?? true
2467
+ })
2468
+ await whenSyncAccepted(applied)
2469
+ if (
2470
+ !this.cacheGenerationClaim &&
2471
+ recoverySequence === this.scopedRecoverySequence
2472
+ ) {
2473
+ this.scopedRecoveryPending = false
2474
+ }
2475
+ await this.runInHydrationScope(async (adapter) => {
2476
+ if (this.recoverQueuedAfterLocalLoadFailure) {
2477
+ this.recoverQueuedAfterLocalLoadFailure = false
2478
+ await this.recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
2479
+ lifecycleGeneration,
2480
+ adapter,
2481
+ )
2482
+ } else {
2483
+ await this.flushQueuedHydrationTransactionsUnsafe(adapter)
2484
+ }
2485
+ })
2486
+ await this.flushQueuedTxCommittedUnsafe()
2487
+ }
2488
+ if (recoverySequence === this.scopedRecoverySequence) {
2489
+ // Older overlapping rotations leave quarantine in place. The final
2490
+ // rotation rebuilds the current index registry before releasing the
2491
+ // mutex; later index events can then target only its storage ID.
2492
+ this.scopedRecoveryPending = false
2493
+ if (this.cacheGenerationClaim) {
2494
+ const currentIndexes = this.collection?.getIndexMetadata() ?? []
2495
+ const completedIndexes = await this.bootstrapPersistedIndexes(
2496
+ currentIndexes,
2497
+ this.persistence.adapter,
2498
+ )
2499
+ await this.requestCoordinatorPersistedIndexes(
2500
+ currentIndexes,
2501
+ completedIndexes,
2502
+ )
2503
+ }
2504
+ }
2505
+ })
2506
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2507
+ resolveCacheRotated?.()
2508
+ await sourceRestart
2509
+ if (
2510
+ recoverySequence === this.scopedRecoverySequence &&
2511
+ this.cacheGenerationClaim
2512
+ ) {
2513
+ this.scheduleCacheClaimRenewal(this.cacheGenerationClaim)
2514
+ }
2515
+ // A newer rotation owns reacquisition of the active demands. Dispatching
2516
+ // them from this older recovery would fetch against its pending cache.
2517
+ if (recoverySequence !== this.scopedRecoverySequence) return
2518
+ for (const options of this.activeSubsets.values()) {
2519
+ if (options.signal?.aborted) continue
2520
+ const rotatedOptions = this.rotatedAcquisitions.get(options)
2521
+ if (rotatedOptions) {
2522
+ const ensure = this.persistence.coordinator.requestEnsureRemoteSubset(
2523
+ this.storageCollectionId,
2524
+ rotatedOptions,
2525
+ )
2526
+ this.rotatedEnsures.set(options, ensure)
2527
+ void ensure.catch((error) => this.reportSyncError(error))
2528
+ } else {
2529
+ this.refreshScopedSubsets()
2530
+ break
2531
+ }
2532
+ }
2533
+ } catch (error) {
2534
+ rejectCacheRotated?.(error)
2535
+ throw this.markTerminalFailure(error, lifecycleGeneration)
2536
+ }
2537
+ }
2538
+
1792
2539
  getResumeGenerationOwner(): symbol {
1793
2540
  return this.resumeGenerationOwner
1794
2541
  }
@@ -1796,7 +2543,7 @@ class PersistedCollectionRuntime<
1796
2543
  private async hydrateBaseline(
1797
2544
  lifecycleGeneration: number,
1798
2545
  adapter: HydrationPersistenceAdapter,
1799
- ): Promise<number | undefined> {
2546
+ ): Promise<number | undefined | false> {
1800
2547
  if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
1801
2548
 
1802
2549
  // The baseline shares the unconstrained demand key. Its lease is never
@@ -1804,7 +2551,7 @@ class PersistedCollectionRuntime<
1804
2551
  const baseline = {}
1805
2552
  this.activeSubsets.set(this.getSubsetKey(baseline), baseline)
1806
2553
  const appliedCursor = this.appliedReceiptSequence
1807
- await this.hydrateSubsetUnsafe(
2554
+ const hydrated = await this.hydrateSubsetUnsafe(
1808
2555
  baseline,
1809
2556
  {
1810
2557
  lifecycleGeneration,
@@ -1812,6 +2559,7 @@ class PersistedCollectionRuntime<
1812
2559
  },
1813
2560
  adapter,
1814
2561
  )
2562
+ if (!hydrated) return false
1815
2563
  return lifecycleGeneration === this.lifecycleGeneration
1816
2564
  ? appliedCursor
1817
2565
  : undefined
@@ -1849,17 +2597,19 @@ class PersistedCollectionRuntime<
1849
2597
  )
1850
2598
  if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
1851
2599
 
1852
- // Let the source run only once the startup hydrate is about to begin.
1853
- // Its first transaction must bind to that hydrate's sequence, not the
1854
- // sequence before index bootstrap yielded.
1855
- onStartupMetadataLoaded()
2600
+ // Eager and unmanaged sources start at the hydrate boundary so their first
2601
+ // transaction binds to its sequence. A managed on-demand source waits
2602
+ // until the scope exits and any concurrent private rotation settles.
2603
+ if (!this.cacheGenerationClaim || this.syncMode !== `on-demand`) {
2604
+ onStartupMetadataLoaded()
2605
+ }
1856
2606
  const appliedCursor =
1857
2607
  this.syncMode !== `on-demand`
1858
2608
  ? await this.hydrateBaseline(lifecycleGeneration, adapter)
1859
2609
  : undefined
1860
2610
  return lifecycleGeneration === this.lifecycleGeneration
1861
2611
  ? {
1862
- appliedCursor,
2612
+ appliedCursor: appliedCursor === false ? undefined : appliedCursor,
1863
2613
  indexBootstrapSnapshot,
1864
2614
  completedLocalIndexSignatures,
1865
2615
  }
@@ -1869,11 +2619,23 @@ class PersistedCollectionRuntime<
1869
2619
  private async loadStartupMetadataInternal(
1870
2620
  lifecycleGeneration: number,
1871
2621
  adapter: HydrationPersistenceAdapter,
1872
- ): Promise<void> {
1873
- const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
1874
- includeRows: false,
1875
- })
1876
- if (lifecycleGeneration !== this.lifecycleGeneration) return
2622
+ ): Promise<boolean> {
2623
+ const claimId = this.cacheGenerationClaim?.claimId
2624
+ const storageCollectionId = this.storageCollectionId
2625
+ const snapshot = await adapter.loadResumeSnapshot(
2626
+ this.storageTargetFor(storageCollectionId, claimId),
2627
+ {
2628
+ includeRows: false,
2629
+ cacheGenerationClaimId: claimId,
2630
+ },
2631
+ )
2632
+ if (lifecycleGeneration !== this.lifecycleGeneration) return false
2633
+ if (claimId !== this.cacheGenerationClaim?.claimId) return false
2634
+ if (storageCollectionId !== this.storageCollectionId) return false
2635
+ if (claimId && !(await this.renewCacheClaim(adapter))) return false
2636
+ if (lifecycleGeneration !== this.lifecycleGeneration) return false
2637
+ if (claimId !== this.cacheGenerationClaim?.claimId) return false
2638
+ if (storageCollectionId !== this.storageCollectionId) return false
1877
2639
  this.persistedResumeGeneration = this.getResumeSnapshotGeneration(snapshot)
1878
2640
  this.persistedKeySetEvidence = snapshot.keySet
1879
2641
  this.observeStreamPosition(
@@ -1885,6 +2647,7 @@ class PersistedCollectionRuntime<
1885
2647
  snapshot.collectionMetadata,
1886
2648
  )
1887
2649
  await whenSyncAccepted(applied)
2650
+ return true
1888
2651
  }
1889
2652
 
1890
2653
  private async loadCollectionMetadataSnapshot(
@@ -1894,7 +2657,9 @@ class PersistedCollectionRuntime<
1894
2657
  return []
1895
2658
  }
1896
2659
 
1897
- return adapter.loadCollectionMetadata(this.collectionId)
2660
+ return adapter.loadCollectionMetadata(this.storageTargetFor(), {
2661
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
2662
+ })
1898
2663
  }
1899
2664
 
1900
2665
  private replaceCollectionMetadataSnapshot(
@@ -1908,35 +2673,39 @@ class PersistedCollectionRuntime<
1908
2673
  return true
1909
2674
  }
1910
2675
 
1911
- const nextMetadata = new Map(
1912
- collectionMetadata.map(({ key, value }) => [key, value]),
1913
- )
1914
- const currentKeys = this.syncControls.metadata.collection
1915
- .list()
1916
- .map(({ key }) => key)
1917
-
1918
2676
  return this.withInternalApply(() => {
1919
2677
  this.syncControls.begin?.()
1920
-
1921
- currentKeys.forEach((key) => {
1922
- if (!nextMetadata.has(key)) {
1923
- this.syncControls.metadata?.collection.delete(key)
1924
- }
1925
- })
1926
-
1927
- nextMetadata.forEach((value, key) => {
1928
- this.syncControls.metadata?.collection.set(key, value)
1929
- })
1930
-
2678
+ this.applyCollectionMetadataSnapshot(collectionMetadata)
1931
2679
  return this.syncControls.commit?.() ?? true
1932
2680
  })
1933
2681
  }
1934
2682
 
2683
+ private applyCollectionMetadataSnapshot(
2684
+ collectionMetadata: Array<{ key: string; value: unknown }>,
2685
+ ): void {
2686
+ const metadata = this.syncControls.metadata
2687
+ if (!metadata) return
2688
+ const next = new Map(
2689
+ collectionMetadata.map(({ key, value }) => [key, value]),
2690
+ )
2691
+ for (const { key } of metadata.collection.list()) {
2692
+ if (!next.has(key)) metadata.collection.delete(key)
2693
+ }
2694
+ for (const [key, value] of next) metadata.collection.set(key, value)
2695
+ }
2696
+
1935
2697
  async loadSubset(
1936
2698
  options: LoadSubsetOptions,
1937
2699
  upstreamLoadSubset?: LoadSubsetFn,
2700
+ onHydrationStarted?: () => void,
2701
+ isDemandRetained?: () => boolean,
1938
2702
  ): Promise<void> {
1939
2703
  this.throwIfTerminal()
2704
+ await this.ensureCacheClaimActive()
2705
+ // The caller can retire demand while claim renewal or rotation is awaited.
2706
+ // No acquisition may start after that retirement.
2707
+ if (options.signal?.aborted || (isDemandRetained && !isDemandRetained()))
2708
+ return
1940
2709
  const lifecycleGeneration = this.lifecycleGeneration
1941
2710
  const subsetKey = this.getSubsetKey(options)
1942
2711
  const truncateGeneration = this.sourceTruncateGeneration
@@ -1947,28 +2716,63 @@ class PersistedCollectionRuntime<
1947
2716
  this.activeSubsets.set(subsetKey, options)
1948
2717
  const appliedCursor = this.appliedReceiptSequence
1949
2718
  try {
1950
- await this.applyMutex.run(async () => {
1951
- try {
1952
- await this.runInHydrationScope((adapter) =>
1953
- this.hydrateSubsetUnsafe(
1954
- options,
1955
- {
1956
- lifecycleGeneration,
1957
- requestLocalLoadFailure: true,
1958
- rejectBufferedReplayFailure: true,
2719
+ const hydrated = await this.applyMutex.run(
2720
+ async (): Promise<boolean | `aborted`> => {
2721
+ if (options.signal?.aborted) return `aborted`
2722
+ try {
2723
+ return await this.runInHydrationScope<boolean | `aborted`>(
2724
+ (adapter) => {
2725
+ if (options.signal?.aborted) {
2726
+ return Promise.resolve(`aborted`)
2727
+ }
2728
+ onHydrationStarted?.()
2729
+ return this.hydrateSubsetUnsafe(
2730
+ options,
2731
+ {
2732
+ lifecycleGeneration,
2733
+ requestLocalLoadFailure: true,
2734
+ rejectBufferedReplayFailure: true,
2735
+ },
2736
+ adapter,
2737
+ )
1959
2738
  },
1960
- adapter,
1961
- ),
1962
- )
1963
- } finally {
1964
- if (
1965
- lifecycleGeneration === this.lifecycleGeneration &&
1966
- !this.getCurrentTerminalFailure()
1967
- ) {
1968
- await this.flushQueuedTxCommittedUnsafe()
2739
+ )
2740
+ } finally {
2741
+ if (
2742
+ lifecycleGeneration === this.lifecycleGeneration &&
2743
+ !this.getCurrentTerminalFailure()
2744
+ ) {
2745
+ await this.flushQueuedTxCommittedUnsafe()
2746
+ }
1969
2747
  }
2748
+ },
2749
+ )
2750
+ if (hydrated === `aborted`) {
2751
+ if (this.activeSubsets.get(subsetKey) === options) {
2752
+ this.activeSubsets.delete(subsetKey)
1970
2753
  }
1971
- })
2754
+ return
2755
+ }
2756
+ if (!hydrated) {
2757
+ // The old cache read is discarded. Recovery reacquires this retained
2758
+ // demand under the new storage ID, so await that acquisition rather
2759
+ // than starting another source request with old request data.
2760
+ await this.ensureCacheClaimActive()
2761
+ const rotatedOptions = this.rotatedAcquisitions.get(options)
2762
+ if (
2763
+ rotatedOptions &&
2764
+ this.activeSubsets.get(subsetKey) === options &&
2765
+ !options.signal?.aborted
2766
+ ) {
2767
+ await (this.rotatedEnsures.get(options) ??
2768
+ this.persistence.coordinator.requestEnsureRemoteSubset(
2769
+ this.storageCollectionId,
2770
+ rotatedOptions,
2771
+ ))
2772
+ await this.applyLeaderCommitsBeforeReply(options)
2773
+ }
2774
+ return
2775
+ }
1972
2776
  if (lifecycleGeneration !== this.lifecycleGeneration) return
1973
2777
  await this.waitForAppliedReceiptsAfter(appliedCursor)
1974
2778
  // Leadership can change while hydration waits. Validate newly remote
@@ -1990,14 +2794,20 @@ class PersistedCollectionRuntime<
1990
2794
  options.signal?.aborted ||
1991
2795
  this.activeSubsets.get(this.getSubsetKey(options)) !== options
1992
2796
  ) {
2797
+ if (
2798
+ options.signal?.aborted &&
2799
+ this.activeSubsets.get(subsetKey) === options
2800
+ ) {
2801
+ this.activeSubsets.delete(subsetKey)
2802
+ }
1993
2803
  return
1994
2804
  }
1995
2805
 
1996
2806
  if (this.canRouteRemoteDemandThroughCoordinator()) {
1997
2807
  try {
1998
2808
  await this.persistence.coordinator.requestEnsureRemoteSubset(
1999
- this.collectionId,
2000
- options,
2809
+ this.storageCollectionId,
2810
+ this.rotatedAcquisitions.get(options) ?? options,
2001
2811
  )
2002
2812
  } catch (error) {
2003
2813
  if (
@@ -2013,6 +2823,7 @@ class PersistedCollectionRuntime<
2013
2823
  this.queueRemoteSubsetEnsure(options)
2014
2824
  throw error
2015
2825
  }
2826
+ await this.applyLeaderCommitsBeforeReply(options)
2016
2827
  return
2017
2828
  }
2018
2829
 
@@ -2040,6 +2851,7 @@ class PersistedCollectionRuntime<
2040
2851
  const failure = this.getCurrentTerminalFailure()
2041
2852
  if (failure) return Promise.reject(failure.error)
2042
2853
  if (
2854
+ this.hasExpiredCacheClaim() ||
2043
2855
  !this.startupSettled ||
2044
2856
  this.isHydratingNow() ||
2045
2857
  this.canRouteRemoteDemandThroughCoordinator() ||
@@ -2102,6 +2914,8 @@ class PersistedCollectionRuntime<
2102
2914
  const subsetKey = this.getSubsetKey(options)
2103
2915
  this.activeSubsets.delete(subsetKey)
2104
2916
  this.pendingRemoteSubsetEnsures.delete(subsetKey)
2917
+ const remoteOptions = this.rotatedAcquisitions.get(options) ?? options
2918
+ this.rotatedAcquisitions.delete(options)
2105
2919
  const demandKey = getLoadSubsetDemandKey(options)
2106
2920
  const stillActive = Array.from(this.activeSubsets.values()).some(
2107
2921
  (active) => getLoadSubsetDemandKey(active) === demandKey,
@@ -2109,7 +2923,7 @@ class PersistedCollectionRuntime<
2109
2923
  if (!stillActive) this.hydratedDemands.delete(demandKey)
2110
2924
  if (this.mode === `sync-present`) {
2111
2925
  void this.persistence.coordinator
2112
- .requestReleaseRemoteSubset(this.collectionId, options)
2926
+ .requestReleaseRemoteSubset(this.storageCollectionId, remoteOptions)
2113
2927
  .catch((error) => {
2114
2928
  this.reportSyncError(error)
2115
2929
  })
@@ -2132,10 +2946,11 @@ class PersistedCollectionRuntime<
2132
2946
 
2133
2947
  async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
2134
2948
  this.throwIfTerminal()
2949
+ await this.ensureCacheClaimActive()
2135
2950
  const lifecycleGeneration = this.lifecycleGeneration
2136
2951
  // A one-shot refresh does not acquire an enduring subscription lease.
2137
- await this.applyMutex.run(async () => {
2138
- await this.runInHydrationScope((adapter) =>
2952
+ const hydrated = await this.applyMutex.run(async () => {
2953
+ const result = await this.runInHydrationScope((adapter) =>
2139
2954
  this.hydrateSubsetUnsafe(
2140
2955
  options,
2141
2956
  {
@@ -2148,7 +2963,9 @@ class PersistedCollectionRuntime<
2148
2963
  if (lifecycleGeneration === this.lifecycleGeneration) {
2149
2964
  await this.flushQueuedTxCommittedUnsafe()
2150
2965
  }
2966
+ return result
2151
2967
  })
2968
+ if (!hydrated) await this.ensureCacheClaimActive()
2152
2969
  }
2153
2970
 
2154
2971
  queueHydrationBufferedTransaction(
@@ -2159,6 +2976,7 @@ class PersistedCollectionRuntime<
2159
2976
  transaction.rejectApplied?.(failure.error)
2160
2977
  return
2161
2978
  }
2979
+ transaction.admittedStorageCollectionId = this.storageCollectionId
2162
2980
  this.queuedHydrationTransactions.push(transaction)
2163
2981
  }
2164
2982
 
@@ -2167,6 +2985,7 @@ class PersistedCollectionRuntime<
2167
2985
  ): Promise<void> {
2168
2986
  const failure = this.getCurrentTerminalFailure()
2169
2987
  if (failure) return Promise.reject(failure.error)
2988
+ transaction.admittedStorageCollectionId = this.storageCollectionId
2170
2989
  return this.applyMutex.run(async () => {
2171
2990
  await this.applyBufferedSyncTransactionUnsafe(transaction)
2172
2991
  })
@@ -2299,7 +3118,10 @@ class PersistedCollectionRuntime<
2299
3118
  attempt(() => {
2300
3119
  remoteReleases.push(
2301
3120
  this.persistence.coordinator
2302
- .requestReleaseRemoteSubset(this.collectionId, options)
3121
+ .requestReleaseRemoteSubset(
3122
+ this.storageCollectionId,
3123
+ this.rotatedAcquisitions.get(options) ?? options,
3124
+ )
2303
3125
  .catch((error) => {
2304
3126
  this.reportSyncError(error)
2305
3127
  }),
@@ -2313,6 +3135,7 @@ class PersistedCollectionRuntime<
2313
3135
 
2314
3136
  attempt(() => this.remoteSubsetOwnerUnsubscribe?.())
2315
3137
  this.remoteSubsetOwnerUnsubscribe = null
3138
+ this.remoteSubsetOwner = undefined
2316
3139
 
2317
3140
  attempt(() => this.indexAddedUnsubscribe?.())
2318
3141
  this.indexAddedUnsubscribe = null
@@ -2326,6 +3149,16 @@ class PersistedCollectionRuntime<
2326
3149
  }
2327
3150
 
2328
3151
  this.pendingRemoteSubsetEnsures.clear()
3152
+ this.rotatedAcquisitions.clear()
3153
+ for (const [options, storageId] of this.scopedRefreshes) {
3154
+ remoteReleases.push(
3155
+ this.persistence.coordinator.requestReleaseRemoteSubset(
3156
+ storageId,
3157
+ options,
3158
+ ),
3159
+ )
3160
+ }
3161
+ this.scopedRefreshes.clear()
2329
3162
  this.activeSubsets.clear()
2330
3163
  this.hydratedDemands.clear()
2331
3164
  for (const transaction of this.queuedHydrationTransactions) {
@@ -2335,6 +3168,15 @@ class PersistedCollectionRuntime<
2335
3168
  this.queuedTxCommitted.length = 0
2336
3169
  this.clearSyncControls()
2337
3170
  this.collection = null
3171
+ const claim = this.cacheGenerationClaim
3172
+ this.cacheGenerationClaim = undefined
3173
+ this.clearCacheClaimRenewal()
3174
+ this.cacheClaimRecovery = null
3175
+ if (claim && this.persistence.adapter.releaseCacheGenerationClaim) {
3176
+ remoteReleases.push(
3177
+ this.persistence.adapter.releaseCacheGenerationClaim(claim.claimId),
3178
+ )
3179
+ }
2338
3180
  return Promise.all(remoteReleases).then(() => {
2339
3181
  if (failures.length === 1) throw failures[0]
2340
3182
  if (failures.length > 1) {
@@ -2347,6 +3189,12 @@ class PersistedCollectionRuntime<
2347
3189
 
2348
3190
  private advanceLifecycle(): void {
2349
3191
  this.lifecycleGeneration++
3192
+ this.clearCacheClaimRenewal()
3193
+ this.cacheClaimRecovery = null
3194
+ this.scopedRecovery = false
3195
+ this.scopedRecoveryPending = false
3196
+ this.scopedRecoverySequence++
3197
+ this.recoverQueuedAfterLocalLoadFailure = false
2350
3198
  this.persistedReadiness?.set({ status: `loading` })
2351
3199
  this.startupSettled = false
2352
3200
  this.publicRowVersion = 0
@@ -2385,8 +3233,10 @@ class PersistedCollectionRuntime<
2385
3233
  options: LoadSubsetOptions,
2386
3234
  adapter: HydrationPersistenceAdapter,
2387
3235
  ): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
2388
- return adapter.loadSubset(this.collectionId, options, {
3236
+ if (this.scopedRecovery) return Promise.resolve([])
3237
+ return adapter.loadSubset(this.storageTargetFor(), options, {
2389
3238
  requiredIndexSignatures: this.getRequiredIndexSignatures(),
3239
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
2390
3240
  }) as Promise<Array<{ key: TKey; value: T; metadata?: unknown }>>
2391
3241
  }
2392
3242
 
@@ -2397,17 +3247,36 @@ class PersistedCollectionRuntime<
2397
3247
  return []
2398
3248
  }
2399
3249
 
2400
- return this.persistence.adapter.scanRows(
2401
- this.collectionId,
2402
- options,
2403
- ) as Promise<Array<PersistedScannedRow<T, TKey>>>
3250
+ return this.persistence.adapter.scanRows(this.storageTargetFor(), options, {
3251
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
3252
+ }) as Promise<Array<PersistedScannedRow<T, TKey>>>
2404
3253
  }
2405
3254
 
2406
3255
  async scanPersistedRows(
2407
3256
  options?: PersistedRowScanOptions,
2408
3257
  ): Promise<Array<PersistedScannedRow<T, TKey>>> {
2409
- this.throwIfTerminal()
2410
- return this.applyMutex.run(() => this.scanPersistedRowsUnsafe(options))
3258
+ for (;;) {
3259
+ this.throwIfTerminal()
3260
+ await this.ensureCacheClaimActive()
3261
+ const claimId = this.cacheGenerationClaim?.claimId
3262
+ const storageCollectionId = this.storageCollectionId
3263
+ const rows = await this.applyMutex.run(() =>
3264
+ this.scanPersistedRowsUnsafe(options),
3265
+ )
3266
+ if (this.scopedRecoveryPending) {
3267
+ throw new SyncTransactionAbortedError()
3268
+ }
3269
+ if (
3270
+ claimId !== this.cacheGenerationClaim?.claimId ||
3271
+ storageCollectionId !== this.storageCollectionId
3272
+ ) {
3273
+ continue
3274
+ }
3275
+ if (claimId && !(await this.renewCacheClaim(this.persistence.adapter))) {
3276
+ continue
3277
+ }
3278
+ return rows
3279
+ }
2411
3280
  }
2412
3281
 
2413
3282
  private async hydrateSubsetUnsafe(
@@ -2419,7 +3288,7 @@ class PersistedCollectionRuntime<
2419
3288
  rejectBufferedReplayFailure?: boolean
2420
3289
  },
2421
3290
  adapter: HydrationPersistenceAdapter,
2422
- ): Promise<void> {
3291
+ ): Promise<boolean> {
2423
3292
  let rowsLoaded = false
2424
3293
  let boundRowVersion: number | undefined
2425
3294
  let replayFailure: { reason: unknown } | undefined
@@ -2432,26 +3301,53 @@ class PersistedCollectionRuntime<
2432
3301
  this.hydratingGeneration = config.lifecycleGeneration
2433
3302
  try {
2434
3303
  let rows: Array<{ key: TKey; value: T; metadata?: unknown }>
2435
- if (config.bindKeySetEvidence) {
2436
- const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
2437
- requiredIndexSignatures: this.getRequiredIndexSignatures(),
2438
- includeRows: true,
2439
- })
3304
+ let bindResumeSnapshot: (() => void) | undefined
3305
+ if (config.bindKeySetEvidence && !this.scopedRecovery) {
3306
+ const snapshot = await adapter.loadResumeSnapshot(
3307
+ this.storageTargetFor(),
3308
+ {
3309
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
3310
+ includeRows: true,
3311
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
3312
+ },
3313
+ )
2440
3314
  rows = snapshot.rows as Array<{
2441
3315
  key: TKey
2442
3316
  value: T
2443
3317
  metadata?: unknown
2444
3318
  }>
2445
- if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2446
- if (resetSequence !== this.resetSequence) return
2447
- this.bindResumeSnapshotEvidence(snapshot)
3319
+ if (config.lifecycleGeneration !== this.lifecycleGeneration)
3320
+ return true
3321
+ if (resetSequence !== this.resetSequence) return true
3322
+ bindResumeSnapshot = () => this.bindResumeSnapshotEvidence(snapshot)
2448
3323
  boundRowVersion = snapshot.latestRowVersion
2449
3324
  } else {
2450
3325
  rows = await this.loadSubsetRowsUnsafe(options, adapter)
2451
3326
  }
3327
+ const complete =
3328
+ this.syncMode === `on-demand` &&
3329
+ !this.scopedRecovery &&
3330
+ options.where === undefined &&
3331
+ options.limit === undefined &&
3332
+ options.offset === undefined &&
3333
+ options.cursor === undefined
3334
+ const validationRows = complete ? rows : undefined
2452
3335
  rowsLoaded = true
2453
- if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2454
- if (resetSequence !== this.resetSequence) return
3336
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
3337
+ if (resetSequence !== this.resetSequence) return true
3338
+
3339
+ // SQLite validated the claim at read admission. A paused sync run
3340
+ // can resume after expiry before its renewal timer runs, so cached
3341
+ // rows need fresh authority before they enter the public Collection.
3342
+ if (
3343
+ this.cacheGenerationClaim &&
3344
+ !(await this.renewCacheClaim(adapter))
3345
+ ) {
3346
+ return false
3347
+ }
3348
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
3349
+ if (resetSequence !== this.resetSequence) return true
3350
+ bindResumeSnapshot?.()
2455
3351
 
2456
3352
  if (
2457
3353
  !config.bindKeySetEvidence ||
@@ -2460,7 +3356,7 @@ class PersistedCollectionRuntime<
2460
3356
  for (const row of rows) {
2461
3357
  hydrationContext.suppliedRowKeys.add(row.key)
2462
3358
  }
2463
- const applied = this.applyRowsToCollection(rows)
3359
+ const applied = this.applyRowsToCollection(rows, validationRows)
2464
3360
  await whenSyncAccepted(applied)
2465
3361
  if (boundRowVersion !== undefined) {
2466
3362
  this.publicRowVersion = Math.max(
@@ -2478,11 +3374,29 @@ class PersistedCollectionRuntime<
2478
3374
  }
2479
3375
  }
2480
3376
 
2481
- if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2482
- replayFailure = await this.flushQueuedHydrationTransactionsUnsafe(adapter)
2483
- if (config.lifecycleGeneration !== this.lifecycleGeneration) return
3377
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
3378
+ if (!this.scopedRecoveryPending) {
3379
+ replayFailure =
3380
+ await this.flushQueuedHydrationTransactionsUnsafe(adapter)
3381
+ }
3382
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
2484
3383
  } catch (error) {
3384
+ // A read admitted under a live claim may fail at the adapter's later
3385
+ // claim check after this run wakes from expiry. Its old cache has no
3386
+ // authority; the retained demand must reacquire under a new generation.
3387
+ if (
3388
+ config.requestLocalLoadFailure &&
3389
+ !rowsLoaded &&
3390
+ this.cacheGenerationClaim &&
3391
+ this.hasExpiredCacheClaim()
3392
+ ) {
3393
+ return false
3394
+ }
2485
3395
  if (config.requestLocalLoadFailure && !rowsLoaded) {
3396
+ if (this.scopedRecoveryPending) {
3397
+ this.recoverQueuedAfterLocalLoadFailure = true
3398
+ throw error
3399
+ }
2486
3400
  // Keep admitting source work to the hydration queue while recovery
2487
3401
  // reconstructs any persisted baseline required by partial updates.
2488
3402
  // The failed subset itself owns no rows, so recovery must evaluate
@@ -2516,6 +3430,7 @@ class PersistedCollectionRuntime<
2516
3430
  if (replayFailure && config.rejectBufferedReplayFailure) {
2517
3431
  throw replayFailure.reason
2518
3432
  }
3433
+ return true
2519
3434
  }
2520
3435
 
2521
3436
  private async recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
@@ -2571,24 +3486,26 @@ class PersistedCollectionRuntime<
2571
3486
  getPresence(key) === `unknown`
2572
3487
  ) {
2573
3488
  if (snapshotRows === undefined) {
2574
- const snapshot = await adapter.loadResumeSnapshot(
2575
- this.collectionId,
2576
- {
2577
- requiredIndexSignatures: this.getRequiredIndexSignatures(),
2578
- includeRows: true,
2579
- },
2580
- )
3489
+ const snapshot = this.scopedRecovery
3490
+ ? undefined
3491
+ : await adapter.loadResumeSnapshot(this.storageTargetFor(), {
3492
+ requiredIndexSignatures:
3493
+ this.getRequiredIndexSignatures(),
3494
+ includeRows: true,
3495
+ cacheGenerationClaimId:
3496
+ this.cacheGenerationClaim?.claimId,
3497
+ })
2581
3498
  this.throwIfLifecycleReplaced(lifecycleGeneration)
2582
3499
  if (transaction.signal?.aborted) break
2583
- snapshotRows = new Map(
2584
- (
2585
- snapshot.rows as Array<{
2586
- key: TKey
2587
- value: T
2588
- metadata?: unknown
2589
- }>
2590
- ).map((row) => [row.key, row]),
2591
- )
3500
+ const rows =
3501
+ snapshot && !this.scopedRecovery
3502
+ ? (snapshot.rows as Array<{
3503
+ key: TKey
3504
+ value: T
3505
+ metadata?: unknown
3506
+ }>)
3507
+ : []
3508
+ snapshotRows = new Map(rows.map((row) => [row.key, row]))
2592
3509
  }
2593
3510
  const baseline = snapshotRows.get(key)
2594
3511
  if (baseline === undefined) {
@@ -2649,6 +3566,8 @@ class PersistedCollectionRuntime<
2649
3566
 
2650
3567
  private applyRowsToCollection(
2651
3568
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
3569
+ validationRows?: Array<{ key: TKey; value: T; metadata?: unknown }>,
3570
+ collectionMetadata?: Array<{ key: string; value: unknown }>,
2652
3571
  ): SyncAppliedReceipt {
2653
3572
  if (
2654
3573
  !this.syncControls.begin ||
@@ -2661,10 +3580,68 @@ class PersistedCollectionRuntime<
2661
3580
  return this.withInternalApply(() => {
2662
3581
  this.syncControls.begin?.()
2663
3582
 
3583
+ // A full durable snapshot validates already public keys. Only the
3584
+ // requested subset may add keys to the public Collection.
3585
+ if (validationRows) {
3586
+ const durableRows = new Map(validationRows.map((row) => [row.key, row]))
3587
+ for (const key of this.collection?.base.keys() ?? []) {
3588
+ if (this.collection?._hasHydratedKey(key)) continue
3589
+ const durable = durableRows.get(key)
3590
+ if (!durable) {
3591
+ this.syncControls.write?.({ type: `delete`, key })
3592
+ } else {
3593
+ const valueChanged = !equalPersistedSnapshotValues(
3594
+ this.collection?.base.get(key),
3595
+ durable.value,
3596
+ )
3597
+ const metadataChanged = !equalPersistedSnapshotValues(
3598
+ this.syncControls.metadata?.row.get(key),
3599
+ durable.metadata,
3600
+ )
3601
+ if (
3602
+ valueChanged ||
3603
+ (metadataChanged && durable.metadata !== undefined)
3604
+ ) {
3605
+ // A persisted read supplies a complete row. In partial-update
3606
+ // mode, deleting it within this sync transaction removes fields
3607
+ // that are absent from the durable value before replacement.
3608
+ if (valueChanged) {
3609
+ this.syncControls.write?.({ type: `delete`, key })
3610
+ }
3611
+ this.syncControls.write?.({
3612
+ type: `update`,
3613
+ value: durable.value,
3614
+ metadata: durable.metadata as
3615
+ Record<string, unknown> | undefined,
3616
+ })
3617
+ }
3618
+ if (metadataChanged && durable.metadata === undefined) {
3619
+ this.syncControls.metadata?.row.delete(key)
3620
+ }
3621
+ }
3622
+ }
3623
+ for (const key of this.collection?._syncedRowMetadataKeysOutsideBase() ??
3624
+ []) {
3625
+ this.syncControls.metadata?.row.delete(key)
3626
+ }
3627
+ }
3628
+
2664
3629
  for (const row of rows) {
2665
- if (this.collection?._hasHydratedKey(row.key)) {
3630
+ if (
3631
+ (validationRows && this.collection?.base.has(row.key)) ||
3632
+ this.collection?._hasHydratedKey(row.key)
3633
+ ) {
2666
3634
  continue
2667
3635
  }
3636
+ if (
3637
+ this.collection?.base.has(row.key) &&
3638
+ !equalPersistedSnapshotValues(
3639
+ this.collection.base.get(row.key),
3640
+ row.value,
3641
+ )
3642
+ ) {
3643
+ this.syncControls.write?.({ type: `delete`, key: row.key })
3644
+ }
2668
3645
  this.syncControls.write?.({
2669
3646
  type: `update`,
2670
3647
  value: row.value,
@@ -2672,6 +3649,9 @@ class PersistedCollectionRuntime<
2672
3649
  })
2673
3650
  }
2674
3651
 
3652
+ if (collectionMetadata)
3653
+ this.applyCollectionMetadataSnapshot(collectionMetadata)
3654
+
2675
3655
  return this.syncControls.commit?.() ?? true
2676
3656
  })
2677
3657
  }
@@ -2743,6 +3723,7 @@ class PersistedCollectionRuntime<
2743
3723
  private replaceCollectionSnapshot(
2744
3724
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
2745
3725
  collectionMetadata: Array<{ key: string; value: unknown }>,
3726
+ replayDemand: boolean,
2746
3727
  ): SyncAppliedReceipt {
2747
3728
  if (
2748
3729
  !this.syncControls.begin ||
@@ -2753,18 +3734,45 @@ class PersistedCollectionRuntime<
2753
3734
  return true
2754
3735
  }
2755
3736
 
2756
- const nextMetadata = new Map(
2757
- collectionMetadata.map(({ key, value }) => [key, value]),
3737
+ const base = this.collection?.base
3738
+ const hydratedKeys = new Set(
3739
+ Array.from(base?.keys() ?? []).filter(
3740
+ (key) => this.collection?._hasHydratedKey(key) === true,
3741
+ ),
2758
3742
  )
2759
- const currentKeys = this.syncControls.metadata.collection
2760
- .list()
2761
- .map(({ key }) => key)
3743
+ const rowKeys = new Set(rows.map((row) => row.key))
3744
+ // A truncate replays the Collection's demand. A follower routes that
3745
+ // demand to its leader, whose source loads and commits again, so a
3746
+ // follower replaces its rows in place, except after a reset, which owes
3747
+ // the replay.
3748
+ const followsLeader =
3749
+ this.canRouteRemoteDemandThroughCoordinator() &&
3750
+ !this.persistence.coordinator.isLeader(this.storageCollectionId)
3751
+ const truncate = hydratedKeys.size === 0 && (replayDemand || !followsLeader)
2762
3752
 
2763
3753
  return this.withInternalApply(() => {
2764
3754
  this.syncControls.begin?.()
2765
- this.syncControls.truncate?.()
3755
+ if (truncate) {
3756
+ this.syncControls.truncate?.()
3757
+ } else {
3758
+ for (const key of base?.keys() ?? []) {
3759
+ if (!hydratedKeys.has(key) && !rowKeys.has(key)) {
3760
+ this.syncControls.write?.({ type: `delete`, key })
3761
+ }
3762
+ }
3763
+ for (const key of this.collection?._syncedRowMetadataKeysOutsideBase() ??
3764
+ []) {
3765
+ this.syncControls.metadata?.row.delete(key)
3766
+ }
3767
+ }
2766
3768
 
2767
3769
  for (const row of rows) {
3770
+ if (hydratedKeys.has(row.key)) continue
3771
+ if (!truncate && base?.has(row.key)) {
3772
+ // Remove stale fields before applying a complete persisted row in
3773
+ // the default partial-update mode, without truncating seed keys.
3774
+ this.syncControls.write?.({ type: `delete`, key: row.key })
3775
+ }
2768
3776
  this.syncControls.write?.({
2769
3777
  type: `update`,
2770
3778
  value: row.value,
@@ -2772,16 +3780,7 @@ class PersistedCollectionRuntime<
2772
3780
  })
2773
3781
  }
2774
3782
 
2775
- currentKeys.forEach((key) => {
2776
- if (!nextMetadata.has(key)) {
2777
- this.syncControls.metadata?.collection.delete(key)
2778
- }
2779
- })
2780
-
2781
- nextMetadata.forEach((value, key) => {
2782
- this.syncControls.metadata?.collection.set(key, value)
2783
- })
2784
-
3783
+ this.applyCollectionMetadataSnapshot(collectionMetadata)
2785
3784
  return this.syncControls.commit?.() ?? true
2786
3785
  })
2787
3786
  }
@@ -2853,6 +3852,42 @@ class PersistedCollectionRuntime<
2853
3852
  }> {
2854
3853
  this.throwIfTerminal()
2855
3854
  this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
3855
+ if (
3856
+ transaction.admittedStorageCollectionId !== undefined &&
3857
+ transaction.admittedStorageCollectionId !== this.storageCollectionId
3858
+ ) {
3859
+ const error = new SyncTransactionAbortedError()
3860
+ if (!transaction.rejectApplied) throw error
3861
+ transaction.rejectApplied(error)
3862
+ return { applied: false }
3863
+ }
3864
+ if (!transaction.internal && this.scopedRecoveryPending) {
3865
+ const error = new SyncTransactionAbortedError()
3866
+ if (!transaction.rejectApplied) throw error
3867
+ transaction.rejectApplied(error)
3868
+ return { applied: false }
3869
+ }
3870
+ if (
3871
+ !transaction.internal &&
3872
+ this.cacheGenerationClaim &&
3873
+ (scopedAdapter ?? this.persistence.adapter).renewCacheGenerationClaim &&
3874
+ !(await this.renewCacheClaim(scopedAdapter ?? this.persistence.adapter))
3875
+ ) {
3876
+ const error = new SyncTransactionAbortedError()
3877
+ // Recovery needs this apply mutex, so start it after the rejected
3878
+ // transaction has released its turn. Its new source snapshots use a
3879
+ // fresh cache generation; this commit must never be replayed into it.
3880
+ queueMicrotask(() => {
3881
+ if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return
3882
+ if (this.scopedRecoveryPending) return
3883
+ void this.startScopedRecovery().catch((failure) => {
3884
+ this.markTerminalFailure(failure, transaction.lifecycleGeneration)
3885
+ })
3886
+ })
3887
+ if (!transaction.rejectApplied) throw error
3888
+ transaction.rejectApplied(error)
3889
+ return { applied: false }
3890
+ }
2856
3891
  const abortedBeforeApplication = transaction.signal?.aborted === true
2857
3892
  let abortedDuringApplication = false
2858
3893
  let applicationReturned = false
@@ -2951,10 +3986,11 @@ class PersistedCollectionRuntime<
2951
3986
  }
2952
3987
  const streamPosition = this.nextLocalStreamPosition()
2953
3988
  const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
3989
+ const storageCollectionId = this.storageCollectionId
2954
3990
  let response: ApplyCommittedTxResponse
2955
3991
  try {
2956
3992
  response = await this.persistence.coordinator.requestApplyCommittedTx(
2957
- this.collectionId,
3993
+ storageCollectionId,
2958
3994
  tx,
2959
3995
  // A hydration-scoped adapter is already inside the shared scheduler.
2960
3996
  // Ordinary source commits enter it before taking the writer lock.
@@ -2971,7 +4007,7 @@ class PersistedCollectionRuntime<
2971
4007
  let reconciled: ReconcileCommittedTxResponse
2972
4008
  try {
2973
4009
  reconciled = await this.persistence.coordinator.reconcileCommittedTx(
2974
- this.collectionId,
4010
+ storageCollectionId,
2975
4011
  tx,
2976
4012
  anchor,
2977
4013
  scopedAdapter,
@@ -3051,7 +4087,10 @@ class PersistedCollectionRuntime<
3051
4087
  this.throwIfTerminal()
3052
4088
  this.throwIfLifecycleReplaced(lifecycleGeneration)
3053
4089
  try {
3054
- await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
4090
+ await this.persistence.adapter.applyCommittedTx(
4091
+ this.storageTargetFor(),
4092
+ tx,
4093
+ )
3055
4094
  } catch (error) {
3056
4095
  throw this.markTerminalFailure(
3057
4096
  toPersistedCollectionDurabilityError(this.collectionId, error),
@@ -3070,6 +4109,7 @@ class PersistedCollectionRuntime<
3070
4109
  term: streamPosition.term,
3071
4110
  seq: streamPosition.seq,
3072
4111
  rowVersion: streamPosition.rowVersion,
4112
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
3073
4113
  truncate: transaction.truncate,
3074
4114
  mutations: transaction.operations.map((operation) =>
3075
4115
  operation.type === `update`
@@ -3114,6 +4154,7 @@ class PersistedCollectionRuntime<
3114
4154
  term: streamPosition.term,
3115
4155
  seq: streamPosition.seq,
3116
4156
  rowVersion: streamPosition.rowVersion,
4157
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
3117
4158
  mutations: mutations.map((mutation) => {
3118
4159
  if (mutation.type === `delete`) {
3119
4160
  return {
@@ -3201,6 +4242,7 @@ class PersistedCollectionRuntime<
3201
4242
  // but leave the coordinator's state.latestSeq stale, causing seq collisions
3202
4243
  // when follower RPCs later arrive.
3203
4244
  if (this.persistence.coordinator.requestApplyLocalMutations) {
4245
+ const target = this.storageTargetFor()
3204
4246
  const envelopeMutations = mutations.map((mutation) =>
3205
4247
  toPersistedMutationEnvelope(
3206
4248
  mutation as unknown as PendingMutation<Record<string, unknown>>,
@@ -3209,8 +4251,9 @@ class PersistedCollectionRuntime<
3209
4251
 
3210
4252
  const response =
3211
4253
  await this.persistence.coordinator.requestApplyLocalMutations(
3212
- this.collectionId,
4254
+ this.storageCollectionId,
3213
4255
  envelopeMutations,
4256
+ target.kind === `managed` ? target.claimId : undefined,
3214
4257
  )
3215
4258
 
3216
4259
  this.throwIfLifecycleReplaced(lifecycleGeneration)
@@ -3345,12 +4388,12 @@ class PersistedCollectionRuntime<
3345
4388
  const envelope: ProtocolEnvelope<TxCommitted> = {
3346
4389
  v: 1,
3347
4390
  dbName: this.dbName,
3348
- collectionId: this.collectionId,
4391
+ collectionId: this.storageCollectionId,
3349
4392
  senderId: this.persistence.coordinator.getNodeId(),
3350
4393
  ts: Date.now(),
3351
4394
  payload: txCommitted,
3352
4395
  }
3353
- this.persistence.coordinator.publish(this.collectionId, envelope)
4396
+ this.persistence.coordinator.publish(this.storageCollectionId, envelope)
3354
4397
  }
3355
4398
 
3356
4399
  private observeStreamPosition(
@@ -3420,7 +4463,7 @@ class PersistedCollectionRuntime<
3420
4463
  // A follower routes demand to the elected owner even when its own source
3421
4464
  // cannot own acquisitions. Only an elected node needs a local owner.
3422
4465
  return (
3423
- !this.persistence.coordinator.isLeader(this.collectionId) ||
4466
+ !this.persistence.coordinator.isLeader(this.storageCollectionId) ||
3424
4467
  this.remoteSubsetOwnerUnsubscribe !== null
3425
4468
  )
3426
4469
  }
@@ -3490,8 +4533,8 @@ class PersistedCollectionRuntime<
3490
4533
  }
3491
4534
  try {
3492
4535
  await this.persistence.coordinator.requestEnsureRemoteSubset(
3493
- this.collectionId,
3494
- options,
4536
+ this.storageCollectionId,
4537
+ this.rotatedAcquisitions.get(options) ?? options,
3495
4538
  )
3496
4539
  this.pendingRemoteSubsetEnsures.delete(subsetKey)
3497
4540
  } catch (error) {
@@ -3515,11 +4558,12 @@ class PersistedCollectionRuntime<
3515
4558
  }
3516
4559
 
3517
4560
  this.persistence.coordinator.setAdapterForCollection?.(
3518
- this.collectionId,
4561
+ this.storageCollectionId,
3519
4562
  this.persistence.adapter,
4563
+ this.cacheGenerationClaim?.claimId,
3520
4564
  )
3521
4565
  this.coordinatorUnsubscribe = this.persistence.coordinator.subscribe(
3522
- this.collectionId,
4566
+ this.storageCollectionId,
3523
4567
  (message) => {
3524
4568
  this.onCoordinatorMessage(message)
3525
4569
  },
@@ -3527,34 +4571,43 @@ class PersistedCollectionRuntime<
3527
4571
  }
3528
4572
 
3529
4573
  private onCoordinatorMessage(message: ProtocolEnvelope<unknown>): void {
3530
- if (message.collectionId !== this.collectionId) {
4574
+ if (message.collectionId !== this.storageCollectionId) {
3531
4575
  return
3532
4576
  }
3533
4577
  if (this.getCurrentTerminalFailure()) {
3534
4578
  return
3535
4579
  }
3536
-
3537
4580
  const { payload } = message
3538
4581
  const lifecycleGeneration = this.lifecycleGeneration
3539
4582
  const isSelf = message.senderId === this.persistence.coordinator.getNodeId()
4583
+ const claimExpired = this.hasExpiredCacheClaim()
4584
+ if (claimExpired && !isTxCommittedPayload(payload)) {
4585
+ this.scheduleCacheClaimRecovery(lifecycleGeneration)
4586
+ return
4587
+ }
3540
4588
 
3541
4589
  // Allow tx:committed from self — the coordinator produces these on behalf
3542
4590
  // of both local and remote mutations. The seq dedup in
3543
4591
  // processCommittedTxUnsafe prevents double-processing of our own writes.
3544
4592
  if (isTxCommittedPayload(payload)) {
3545
- if (this.isHydratingNow()) {
3546
- this.queuedTxCommitted.push({
3547
- txCommitted: payload,
3548
- lifecycleGeneration,
3549
- })
3550
- return
4593
+ // Commits apply in arrival order through the queue. While the claim is
4594
+ // expired, keep them until renewal or rotation decides this generation;
4595
+ // a renewed generation still owes its rows. A hydration flushes the
4596
+ // queue when it ends.
4597
+ this.queuedTxCommitted.push({
4598
+ txCommitted: payload,
4599
+ lifecycleGeneration,
4600
+ storageCollectionId: message.collectionId,
4601
+ })
4602
+ if (claimExpired) {
4603
+ this.scheduleCacheClaimRecovery(lifecycleGeneration)
4604
+ } else if (!this.isHydratingNow()) {
4605
+ void this.applyMutex
4606
+ .run(() => this.flushQueuedTxCommittedUnsafe())
4607
+ .catch((error) => {
4608
+ this.markTerminalFailure(error, lifecycleGeneration)
4609
+ })
3551
4610
  }
3552
-
3553
- void this.applyMutex
3554
- .run(() => this.processCommittedTxUnsafe(payload, lifecycleGeneration))
3555
- .catch((error) => {
3556
- this.markTerminalFailure(error, lifecycleGeneration)
3557
- })
3558
4611
  return
3559
4612
  }
3560
4613
 
@@ -3566,6 +4619,7 @@ class PersistedCollectionRuntime<
3566
4619
  if (isCollectionResetPayload(payload)) {
3567
4620
  void this.applyMutex
3568
4621
  .run(async () => {
4622
+ if (message.collectionId !== this.storageCollectionId) return
3569
4623
  await this.runInHydrationScope(async (adapter) => {
3570
4624
  await this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
3571
4625
  await this.rebaseDurableStreamFromSnapshotUnsafe(
@@ -3591,14 +4645,26 @@ class PersistedCollectionRuntime<
3591
4645
 
3592
4646
  private async flushQueuedTxCommittedUnsafe(): Promise<void> {
3593
4647
  while (this.queuedTxCommitted.length > 0) {
4648
+ // Claim recovery flushes the queue once the generation is decided.
4649
+ if (this.hasExpiredCacheClaim()) {
4650
+ this.scheduleCacheClaimRecovery(this.lifecycleGeneration)
4651
+ return
4652
+ }
3594
4653
  const queued = this.queuedTxCommitted.shift()
3595
4654
  if (!queued) {
3596
4655
  continue
3597
4656
  }
3598
- await this.processCommittedTxUnsafe(
3599
- queued.txCommitted,
3600
- queued.lifecycleGeneration,
3601
- )
4657
+ if (queued.storageCollectionId !== this.storageCollectionId) continue
4658
+ // A queued commit has left the queue, so a failure must be terminal,
4659
+ // as it is for a commit applied when it arrives.
4660
+ try {
4661
+ await this.processCommittedTxUnsafe(
4662
+ queued.txCommitted,
4663
+ queued.lifecycleGeneration,
4664
+ )
4665
+ } catch (error) {
4666
+ throw this.markTerminalFailure(error, queued.lifecycleGeneration)
4667
+ }
3602
4668
  }
3603
4669
  }
3604
4670
 
@@ -3656,7 +4722,7 @@ class PersistedCollectionRuntime<
3656
4722
  hasGapAcrossTerms
3657
4723
 
3658
4724
  if (hasGap) {
3659
- await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
4725
+ if (!(await this.recoverFromSeqGapUnsafe(lifecycleGeneration))) return
3660
4726
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3661
4727
  if (txCommitted.latestRowVersion <= this.publicRowVersion) return
3662
4728
  }
@@ -3685,8 +4751,9 @@ class PersistedCollectionRuntime<
3685
4751
  lifecycleGeneration: number,
3686
4752
  ): Promise<void> {
3687
4753
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3688
- const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
4754
+ const snapshot = await adapter.loadResumeSnapshot(this.storageTargetFor(), {
3689
4755
  includeRows: false,
4756
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
3690
4757
  })
3691
4758
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3692
4759
  // The reset epoch can restart row versions at zero. Keep the term/seq
@@ -3702,21 +4769,35 @@ class PersistedCollectionRuntime<
3702
4769
 
3703
4770
  private async recoverFromSeqGapUnsafe(
3704
4771
  lifecycleGeneration: number,
3705
- ): Promise<void> {
3706
- if (lifecycleGeneration !== this.lifecycleGeneration) return
4772
+ ): Promise<boolean> {
4773
+ if (lifecycleGeneration !== this.lifecycleGeneration) return false
3707
4774
  if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
3708
4775
  let pullResponse: PullSinceResponse | undefined
3709
4776
  try {
3710
4777
  pullResponse = await this.persistence.coordinator.pullSince(
3711
- this.collectionId,
4778
+ this.storageCollectionId,
3712
4779
  this.publicRowVersion,
4780
+ undefined,
4781
+ this.cacheGenerationClaim?.claimId,
3713
4782
  )
3714
4783
  } catch (error) {
3715
4784
  console.warn(`Failed pullSince recovery attempt:`, error)
3716
4785
  }
3717
4786
 
4787
+ // The replay read may have started under a live claim and returned
4788
+ // after expiry. Recovery needs the apply mutex held by this callback,
4789
+ // so schedule it after discarding the old response.
4790
+ if (this.scopedRecoveryPending) return false
4791
+ if (
4792
+ this.cacheGenerationClaim &&
4793
+ !(await this.renewCacheClaim(this.persistence.adapter))
4794
+ ) {
4795
+ this.scheduleCacheClaimRecovery(lifecycleGeneration)
4796
+ return false
4797
+ }
4798
+
3718
4799
  if (pullResponse) {
3719
- if (lifecycleGeneration !== this.lifecycleGeneration) return
4800
+ if (lifecycleGeneration !== this.lifecycleGeneration) return false
3720
4801
 
3721
4802
  if (pullResponse.ok) {
3722
4803
  this.observeStreamPosition(
@@ -3732,7 +4813,7 @@ class PersistedCollectionRuntime<
3732
4813
  this.publicRowVersion,
3733
4814
  pullResponse.latestRowVersion,
3734
4815
  )
3735
- return
4816
+ return true
3736
4817
  }
3737
4818
  const deltas = pullResponse.deltas
3738
4819
  if (!deltas) {
@@ -3743,7 +4824,7 @@ class PersistedCollectionRuntime<
3743
4824
  this.publicRowVersion,
3744
4825
  pullResponse.latestRowVersion,
3745
4826
  )
3746
- return
4827
+ return true
3747
4828
  }
3748
4829
 
3749
4830
  await this.runInHydrationScope(async (adapter) => {
@@ -3772,12 +4853,12 @@ class PersistedCollectionRuntime<
3772
4853
  this.publicRowVersion,
3773
4854
  pullResponse.latestRowVersion,
3774
4855
  )
3775
- return
4856
+ return true
3776
4857
  }
3777
4858
  }
3778
4859
  }
3779
4860
 
3780
- if (lifecycleGeneration !== this.lifecycleGeneration) return
4861
+ if (lifecycleGeneration !== this.lifecycleGeneration) return false
3781
4862
  // A gap is not a schema reset. Replace the durable snapshot in one
3782
4863
  // publication so readers never see an empty intermediate Collection.
3783
4864
  await this.runInHydrationScope((adapter) =>
@@ -3793,6 +4874,53 @@ class PersistedCollectionRuntime<
3793
4874
  this.queueRemoteSubsetEnsure(options)
3794
4875
  }
3795
4876
  }
4877
+ return true
4878
+ }
4879
+
4880
+ private refreshScopedSubsets(): void {
4881
+ if (!this.remoteSubsetOwner) return
4882
+ const lifecycleGeneration = this.lifecycleGeneration
4883
+ const storageId = this.storageCollectionId
4884
+ for (const [subsetKey, options] of this.activeSubsets) {
4885
+ if (options.signal?.aborted) continue
4886
+ this.hydratedDemands.delete(getLoadSubsetDemandKey(options))
4887
+ // A scoped cache cannot answer the invalidation. This one-shot
4888
+ // acquisition requests source evidence while the original demand keeps
4889
+ // its lease; releasing it after settlement preserves exact ownership.
4890
+ const refreshOptions: LoadSubsetOptions = { ...options, refetch: true }
4891
+ this.scopedRefreshes.set(refreshOptions, storageId)
4892
+ void Promise.resolve()
4893
+ .then(() => {
4894
+ if (
4895
+ lifecycleGeneration !== this.lifecycleGeneration ||
4896
+ this.activeSubsets.get(subsetKey) !== options
4897
+ ) {
4898
+ return
4899
+ }
4900
+ return this.persistence.coordinator.requestEnsureRemoteSubset(
4901
+ storageId,
4902
+ refreshOptions,
4903
+ )
4904
+ })
4905
+ .catch((error: unknown) => {
4906
+ if (
4907
+ lifecycleGeneration === this.lifecycleGeneration &&
4908
+ !options.signal?.aborted
4909
+ ) {
4910
+ this.reportSyncError(error)
4911
+ }
4912
+ })
4913
+ .finally(() => {
4914
+ if (!this.scopedRefreshes.delete(refreshOptions)) return
4915
+ void this.persistence.coordinator
4916
+ .requestReleaseRemoteSubset(storageId, refreshOptions)
4917
+ .catch((error: unknown) => {
4918
+ if (lifecycleGeneration === this.lifecycleGeneration) {
4919
+ this.reportSyncError(error)
4920
+ }
4921
+ })
4922
+ })
4923
+ }
3796
4924
  }
3797
4925
 
3798
4926
  private async truncateAndReloadUnsafe(
@@ -3800,10 +4928,21 @@ class PersistedCollectionRuntime<
3800
4928
  lifecycleGeneration = this.lifecycleGeneration,
3801
4929
  ): Promise<void> {
3802
4930
  if (lifecycleGeneration !== this.lifecycleGeneration) return
4931
+ if (this.scopedRecovery) {
4932
+ this.refreshScopedSubsets()
4933
+ return
4934
+ }
3803
4935
  // A subscriber may reacquire reentrantly from the truncate commit.
3804
4936
  this.hydratedDemands.clear()
3805
4937
  this.resetSequence++
3806
- if (this.syncControls.begin && this.syncControls.commit) {
4938
+ // On-demand reset keeps previously public rows and hydration seeds until
4939
+ // the new durable snapshot can replace them in one publication. Active
4940
+ // demand may add rows; retired demand may only validate visible keys.
4941
+ if (
4942
+ this.syncMode === `eager` &&
4943
+ this.syncControls.begin &&
4944
+ this.syncControls.commit
4945
+ ) {
3807
4946
  const applied = this.withInternalApply(() => {
3808
4947
  this.syncControls.begin?.()
3809
4948
  this.syncControls.truncate?.()
@@ -3813,13 +4952,24 @@ class PersistedCollectionRuntime<
3813
4952
  }
3814
4953
 
3815
4954
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3816
- await this.reloadActiveSubsetsUnsafe(adapter)
4955
+ // A reset replays demand: the owner's reset rows return only when the
4956
+ // follower requests its demand again.
4957
+ await this.reloadActiveSubsetsUnsafe(
4958
+ adapter,
4959
+ false,
4960
+ this.syncMode === `eager`,
4961
+ true,
4962
+ )
3817
4963
  }
3818
4964
 
3819
4965
  private async invalidateFromCommittedTxUnsafe(
3820
4966
  txCommitted: TxCommitted,
3821
4967
  adapter: HydrationPersistenceAdapter,
3822
4968
  ): Promise<void> {
4969
+ if (this.scopedRecovery) {
4970
+ this.refreshScopedSubsets()
4971
+ return
4972
+ }
3823
4973
  const reloadActiveSubsets = (skipUnchangedReplace = false) =>
3824
4974
  this.runInHydrationScope(
3825
4975
  (scopedAdapter) =>
@@ -3843,6 +4993,38 @@ class PersistedCollectionRuntime<
3843
4993
  (opt) => opt.limit != null || opt.offset != null || opt.cursor != null,
3844
4994
  )
3845
4995
 
4996
+ // A targeted envelope separates changed rows from deleted keys and omits
4997
+ // insert/update identity. A same-key insert replaces the durable row,
4998
+ // while an update merges omitted fields. The public row cannot decide
4999
+ // which operation produced a changed value. A previously unseen key also
5000
+ // has no public baseline with which to complete a source patch. Read the
5001
+ // durable result whenever that ambiguity affects a public row or demand.
5002
+ const changedKeys = new Set(txCommitted.changedRows.map((row) => row.key))
5003
+ const hasRepeatedChange =
5004
+ changedKeys.size !== txCommitted.changedRows.length
5005
+ const hasChangedAndDeletedKey = txCommitted.deletedKeys.some((key) =>
5006
+ changedKeys.has(key),
5007
+ )
5008
+ const hasChangedPublicKey = txCommitted.changedRows.some((row) =>
5009
+ this.collection?.base.has(row.key as TKey),
5010
+ )
5011
+ const hasUnseenDemandedKey =
5012
+ this.activeSubsets.size > 0 &&
5013
+ txCommitted.changedRows.some(
5014
+ (row) =>
5015
+ !this.collection?.base.has(row.key as TKey) &&
5016
+ !this.collection?._hasHydratedKey(row.key as TKey),
5017
+ )
5018
+ if (
5019
+ hasRepeatedChange ||
5020
+ hasChangedAndDeletedKey ||
5021
+ hasChangedPublicKey ||
5022
+ (this.syncMode === `on-demand` && hasUnseenDemandedKey)
5023
+ ) {
5024
+ await reloadActiveSubsets()
5025
+ return
5026
+ }
5027
+
3846
5028
  if (!hasPaginatedSubset || changedKeyCount === 0) {
3847
5029
  await this.applyTargetedInvalidationUnsafe(txCommitted)
3848
5030
  return
@@ -3867,23 +5049,40 @@ class PersistedCollectionRuntime<
3867
5049
  key: changedKey,
3868
5050
  value: newValue,
3869
5051
  } of txCommitted.changedRows) {
3870
- const matchesAnySubset = subsetEvaluators.some((evaluator) => {
3871
- if (!evaluator) return true
3872
- return toBooleanPredicate(evaluator(newValue) as boolean | null)
3873
- })
3874
-
3875
- if (matchesAnySubset) {
3876
- this.syncControls.write?.({ type: `update`, value: newValue as T })
3877
- } else if (this.collection?.get(changedKey as TKey) !== undefined) {
3878
- this.syncControls.write?.({ type: `delete`, key: changedKey as TKey })
5052
+ const key = changedKey as TKey
5053
+ if (this.collection?._hasHydratedKey(key)) continue
5054
+ const previous = this.collection?.base.get(key)
5055
+ if (previous === undefined && this.activeSubsets.size === 0) continue
5056
+ const completeValue = {
5057
+ ...(previous as object | undefined),
5058
+ ...newValue,
5059
+ } as T
5060
+ const currentlyDemanded = matchesAnySubset(
5061
+ completeValue,
5062
+ subsetEvaluators,
5063
+ )
5064
+ const previouslyOutsideDemand =
5065
+ previous !== undefined &&
5066
+ !matchesAnySubset(previous, subsetEvaluators)
5067
+
5068
+ if (currentlyDemanded || previouslyOutsideDemand) {
5069
+ this.syncControls.write?.({ type: `update`, value: completeValue })
5070
+ } else if (previous !== undefined) {
5071
+ this.syncControls.write?.({ type: `delete`, key })
3879
5072
  }
3880
5073
  }
3881
5074
 
3882
5075
  for (const deletedKey of txCommitted.deletedKeys) {
3883
- this.syncControls.write?.({ type: `delete`, key: deletedKey as TKey })
5076
+ const key = deletedKey as TKey
5077
+ if (!this.collection?._hasHydratedKey(key)) {
5078
+ this.syncControls.write?.({ type: `delete`, key })
5079
+ }
3884
5080
  }
3885
5081
 
3886
5082
  txCommitted.rowMetadataMutations?.forEach((mutation) => {
5083
+ // A DbClient hydration seed owns both its row and row metadata until
5084
+ // an accepted source transaction confirms the key.
5085
+ if (this.collection?._hasHydratedKey(mutation.key as TKey)) return
3887
5086
  if (mutation.type === `delete`) {
3888
5087
  this.syncControls.metadata?.row.delete(mutation.key as TKey)
3889
5088
  } else {
@@ -3913,13 +5112,39 @@ class PersistedCollectionRuntime<
3913
5112
  private async reloadActiveSubsetsUnsafe(
3914
5113
  adapter: HydrationPersistenceAdapter,
3915
5114
  skipUnchangedReplace = false,
5115
+ restoreResetBaseline = false,
5116
+ replayDemand = false,
3916
5117
  ): Promise<void> {
5118
+ if (this.scopedRecovery) {
5119
+ this.refreshScopedSubsets()
5120
+ return
5121
+ }
3917
5122
  const lifecycleGeneration = this.lifecycleGeneration
3918
5123
  const truncateGeneration = this.sourceTruncateGeneration
3919
- const activeSubsetOptions =
3920
- this.activeSubsets.size > 0
3921
- ? Array.from(this.activeSubsets.values())
3922
- : [{}]
5124
+ const activeSubsetOptions = Array.from(this.activeSubsets.values())
5125
+ if (restoreResetBaseline && activeSubsetOptions.length === 0) {
5126
+ activeSubsetOptions.push({})
5127
+ }
5128
+ const evaluators = activeSubsetOptions.map((options) =>
5129
+ options.where ? compileSingleRowExpression(options.where) : null,
5130
+ )
5131
+ // A filtered read has no authority over public rows that were already
5132
+ // outside every active predicate. Validate those keys against the complete
5133
+ // durable snapshot; only active demand may admit previously unseen keys.
5134
+ const outsideDemandKeys = activeSubsetOptions.some(
5135
+ (options) =>
5136
+ options.limit != null ||
5137
+ options.offset != null ||
5138
+ options.cursor != null,
5139
+ )
5140
+ ? []
5141
+ : Array.from(this.collection?.base ?? [])
5142
+ .filter(
5143
+ ([key, row]) =>
5144
+ !this.collection?._hasHydratedKey(key) &&
5145
+ !matchesAnySubset(row, evaluators),
5146
+ )
5147
+ .map(([key]) => key)
3923
5148
 
3924
5149
  this.hydratedDemands.clear()
3925
5150
  this.hydrationSequence++
@@ -3928,25 +5153,95 @@ class PersistedCollectionRuntime<
3928
5153
  this.hydratingGeneration = lifecycleGeneration
3929
5154
  try {
3930
5155
  const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
3931
- const collectionMetadata =
3932
- await this.loadCollectionMetadataSnapshot(adapter)
3933
- if (lifecycleGeneration !== this.lifecycleGeneration) return
3934
- for (const options of activeSubsetOptions) {
3935
- const subsetRows = await this.loadSubsetRowsUnsafe(options, adapter)
5156
+ let validationSnapshot:
5157
+ Awaited<ReturnType<typeof adapter.loadResumeSnapshot>> | undefined
5158
+ let collectionMetadata: Array<{ key: string; value: unknown }> = []
5159
+ try {
5160
+ validationSnapshot =
5161
+ (activeSubsetOptions.length === 0 || outsideDemandKeys.length > 0) &&
5162
+ this.collection?.base.size
5163
+ ? await adapter.loadResumeSnapshot(this.storageTargetFor(), {
5164
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
5165
+ includeRows: true,
5166
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
5167
+ })
5168
+ : undefined
5169
+ collectionMetadata = validationSnapshot
5170
+ ? validationSnapshot.collectionMetadata
5171
+ : await this.loadCollectionMetadataSnapshot(adapter)
3936
5172
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3937
- for (const row of subsetRows) {
3938
- mergedRows.set(row.key, {
3939
- value: row.value,
3940
- metadata: row.metadata,
3941
- })
5173
+ for (const options of activeSubsetOptions) {
5174
+ const subsetRows = await this.loadSubsetRowsUnsafe(options, adapter)
5175
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
5176
+ for (const row of subsetRows) {
5177
+ mergedRows.set(row.key, {
5178
+ value: row.value,
5179
+ metadata: row.metadata,
5180
+ })
5181
+ }
5182
+ }
5183
+ if (outsideDemandKeys.length > 0 && validationSnapshot) {
5184
+ const durableRows = new Map(
5185
+ validationSnapshot.rows.map((row) => [row.key, row]),
5186
+ )
5187
+ for (const key of outsideDemandKeys) {
5188
+ if (mergedRows.has(key)) continue
5189
+ const row = durableRows.get(key)
5190
+ if (row) {
5191
+ mergedRows.set(key, {
5192
+ value: row.value as T,
5193
+ metadata: row.metadata,
5194
+ })
5195
+ }
5196
+ }
5197
+ }
5198
+ } catch (error) {
5199
+ // Recovery may have started while the adapter read was awaited.
5200
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
5201
+ if (this.scopedRecovery) return
5202
+ // SQLite may reject its second read-admission check after a claim
5203
+ // expires. A read from that cache has no authority to fail the
5204
+ // Collection; recovery will reload its active demands.
5205
+ if (!this.cacheGenerationClaim || !this.hasExpiredCacheClaim()) {
5206
+ throw error
3942
5207
  }
5208
+ this.scheduleCacheClaimRecovery(lifecycleGeneration)
5209
+ return
5210
+ }
5211
+
5212
+ // A peer reload can hold its persisted read past claim expiry. Its
5213
+ // rows and metadata cannot publish under the old generation. Recovery
5214
+ // needs this apply mutex, so start it only after this callback returns.
5215
+ if (this.isScopedRecoveryPending()) return
5216
+ if (this.cacheGenerationClaim && !(await this.renewCacheClaim(adapter))) {
5217
+ this.scheduleCacheClaimRecovery(lifecycleGeneration)
5218
+ return
3943
5219
  }
5220
+ if (
5221
+ this.isScopedRecoveryPending() ||
5222
+ lifecycleGeneration !== this.lifecycleGeneration
5223
+ )
5224
+ return
3944
5225
 
3945
5226
  for (const key of mergedRows.keys()) {
3946
5227
  hydrationContext.suppliedRowKeys.add(key)
3947
5228
  }
3948
5229
 
3949
- if (
5230
+ if (activeSubsetOptions.length === 0) {
5231
+ // Retired demand does not hide already public rows. Validate only
5232
+ // those keys; do not publish unrelated durable rows without demand.
5233
+ await whenSyncAccepted(
5234
+ this.applyRowsToCollection(
5235
+ [],
5236
+ (validationSnapshot?.rows ?? []) as Array<{
5237
+ key: TKey
5238
+ value: T
5239
+ metadata?: unknown
5240
+ }>,
5241
+ collectionMetadata,
5242
+ ),
5243
+ )
5244
+ } else if (
3950
5245
  skipUnchangedReplace &&
3951
5246
  this.matchesCollectionSnapshot(mergedRows, collectionMetadata)
3952
5247
  ) {
@@ -3972,6 +5267,7 @@ class PersistedCollectionRuntime<
3972
5267
  metadata: row.metadata,
3973
5268
  })),
3974
5269
  collectionMetadata,
5270
+ replayDemand,
3975
5271
  )
3976
5272
  await whenSyncAccepted(applied)
3977
5273
  }
@@ -4014,7 +5310,12 @@ class PersistedCollectionRuntime<
4014
5310
  }
4015
5311
 
4016
5312
  this.indexAddedUnsubscribe = this.collection.on(`index:added`, (event) => {
4017
- void this.ensurePersistedIndex(event.index)
5313
+ void this.ensurePersistedIndex(event.index).catch((error) => {
5314
+ console.warn(
5315
+ `Failed to ensure persisted index after index:added:`,
5316
+ error,
5317
+ )
5318
+ })
4018
5319
  })
4019
5320
  this.indexRemovedUnsubscribe = this.collection.on(
4020
5321
  `index:removed`,
@@ -4036,8 +5337,22 @@ class PersistedCollectionRuntime<
4036
5337
  const indexMetadata =
4037
5338
  indexMetadataSnapshot ?? collection?.getIndexMetadata() ?? []
4038
5339
  const completedLocalIndexSignatures = new Set<string>()
5340
+ const lifecycleGeneration = this.lifecycleGeneration
4039
5341
  for (const metadata of indexMetadata) {
4040
- if (await this.ensureLocalPersistedIndex(metadata, adapter)) {
5342
+ const storageCollectionId = this.storageCollectionId
5343
+ const claimId = this.cacheGenerationClaim?.claimId
5344
+ const ensured = await this.ensureLocalPersistedIndex(metadata, adapter)
5345
+ if (
5346
+ ensured &&
5347
+ (await this.reconcilePersistedIndex(
5348
+ metadata,
5349
+ adapter,
5350
+ storageCollectionId,
5351
+ claimId,
5352
+ lifecycleGeneration,
5353
+ true,
5354
+ ))
5355
+ ) {
4041
5356
  completedLocalIndexSignatures.add(metadata.signature)
4042
5357
  }
4043
5358
  }
@@ -4048,12 +5363,61 @@ class PersistedCollectionRuntime<
4048
5363
  indexMetadata: Array<CollectionIndexMetadata>,
4049
5364
  completedLocalIndexSignatures: ReadonlySet<string>,
4050
5365
  ): Promise<void> {
5366
+ const lifecycleGeneration = this.lifecycleGeneration
4051
5367
  for (const metadata of indexMetadata) {
5368
+ if (!this.hasCurrentIndex(metadata)) continue
5369
+ const storageCollectionId = this.storageCollectionId
5370
+ const claimId = this.cacheGenerationClaim?.claimId
4052
5371
  await this.requestCoordinatorPersistedIndex(
4053
5372
  metadata,
4054
5373
  completedLocalIndexSignatures.has(metadata.signature),
4055
5374
  )
5375
+ await this.reconcilePersistedIndex(
5376
+ metadata,
5377
+ this.persistence.adapter,
5378
+ storageCollectionId,
5379
+ claimId,
5380
+ lifecycleGeneration,
5381
+ true,
5382
+ )
5383
+ }
5384
+ }
5385
+
5386
+ /** Settle a late index operation against the Collection's current declaration. */
5387
+ private async reconcilePersistedIndex(
5388
+ indexMetadata: CollectionIndexMetadata,
5389
+ adapter: HydrationPersistenceAdapter,
5390
+ storageCollectionId: string,
5391
+ claimId: string | undefined,
5392
+ lifecycleGeneration: number,
5393
+ indexPresent: boolean,
5394
+ ): Promise<boolean> {
5395
+ while (
5396
+ lifecycleGeneration === this.lifecycleGeneration &&
5397
+ storageCollectionId === this.storageCollectionId
5398
+ ) {
5399
+ const declared = this.hasCurrentIndex(indexMetadata)
5400
+ if (declared === indexPresent) return declared
5401
+ if (declared) {
5402
+ if (!(await this.ensureLocalPersistedIndex(indexMetadata, adapter))) {
5403
+ return false
5404
+ }
5405
+ } else {
5406
+ if (!adapter.markIndexRemoved) return false
5407
+ try {
5408
+ await adapter.markIndexRemoved(
5409
+ this.storageTargetFor(storageCollectionId, claimId),
5410
+ indexMetadata.signature,
5411
+ { cacheGenerationClaimId: claimId },
5412
+ )
5413
+ } catch (error) {
5414
+ console.warn(`Failed to mark persisted index removed:`, error)
5415
+ return false
5416
+ }
5417
+ }
5418
+ indexPresent = declared
4056
5419
  }
5420
+ return false
4057
5421
  }
4058
5422
 
4059
5423
  private buildPersistedIndexSpec(
@@ -4073,11 +5437,39 @@ class PersistedCollectionRuntime<
4073
5437
  indexMetadata: CollectionIndexMetadata,
4074
5438
  adapter: HydrationPersistenceAdapter = this.persistence.adapter,
4075
5439
  ): Promise<void> {
5440
+ if (this.scopedRecoveryPending) return
5441
+ const lifecycleGeneration = this.lifecycleGeneration
5442
+ await this.ensureCacheClaimActive()
5443
+ if (
5444
+ lifecycleGeneration !== this.lifecycleGeneration ||
5445
+ !this.hasCurrentIndex(indexMetadata)
5446
+ )
5447
+ return
4076
5448
  const completedLocally = await this.ensureLocalPersistedIndex(
4077
5449
  indexMetadata,
4078
5450
  adapter,
4079
5451
  )
5452
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
5453
+ if (!this.hasCurrentIndex(indexMetadata)) {
5454
+ await this.markIndexRemoved(indexMetadata)
5455
+ return
5456
+ }
4080
5457
  await this.requestCoordinatorPersistedIndex(indexMetadata, completedLocally)
5458
+ if (
5459
+ lifecycleGeneration === this.lifecycleGeneration &&
5460
+ !this.hasCurrentIndex(indexMetadata)
5461
+ ) {
5462
+ await this.markIndexRemoved(indexMetadata)
5463
+ }
5464
+ }
5465
+
5466
+ private hasCurrentIndex(indexMetadata: CollectionIndexMetadata): boolean {
5467
+ return (
5468
+ this.collection
5469
+ ?.getIndexMetadata()
5470
+ .some((current) => current.signature === indexMetadata.signature) ??
5471
+ false
5472
+ )
4081
5473
  }
4082
5474
 
4083
5475
  private async ensureLocalPersistedIndex(
@@ -4087,9 +5479,10 @@ class PersistedCollectionRuntime<
4087
5479
  try {
4088
5480
  const spec = this.buildPersistedIndexSpec(indexMetadata)
4089
5481
  await adapter.ensureIndex(
4090
- this.collectionId,
5482
+ this.storageTargetFor(),
4091
5483
  indexMetadata.signature,
4092
5484
  spec,
5485
+ { cacheGenerationClaimId: this.cacheGenerationClaim?.claimId },
4093
5486
  )
4094
5487
  return true
4095
5488
  } catch (error) {
@@ -4105,11 +5498,12 @@ class PersistedCollectionRuntime<
4105
5498
  try {
4106
5499
  const spec = this.buildPersistedIndexSpec(indexMetadata)
4107
5500
  await this.persistence.coordinator.requestEnsurePersistedIndex(
4108
- this.collectionId,
5501
+ this.storageCollectionId,
4109
5502
  indexMetadata.signature,
4110
5503
  spec,
4111
5504
  completedLocally ? this.persistence.adapter : undefined,
4112
5505
  completedLocally,
5506
+ this.cacheGenerationClaim?.claimId,
4113
5507
  )
4114
5508
  } catch (error) {
4115
5509
  console.warn(
@@ -4127,10 +5521,25 @@ class PersistedCollectionRuntime<
4127
5521
  }
4128
5522
 
4129
5523
  try {
5524
+ if (this.scopedRecoveryPending) return
5525
+ const lifecycleGeneration = this.lifecycleGeneration
5526
+ await this.ensureCacheClaimActive()
5527
+ if (
5528
+ lifecycleGeneration !== this.lifecycleGeneration ||
5529
+ this.hasCurrentIndex(indexMetadata)
5530
+ )
5531
+ return
4130
5532
  await this.persistence.adapter.markIndexRemoved(
4131
- this.collectionId,
5533
+ this.storageTargetFor(),
4132
5534
  indexMetadata.signature,
5535
+ { cacheGenerationClaimId: this.cacheGenerationClaim?.claimId },
4133
5536
  )
5537
+ if (
5538
+ lifecycleGeneration === this.lifecycleGeneration &&
5539
+ this.hasCurrentIndex(indexMetadata)
5540
+ ) {
5541
+ await this.ensurePersistedIndex(indexMetadata)
5542
+ }
4134
5543
  } catch (error) {
4135
5544
  console.warn(`Failed to mark persisted index removed:`, error)
4136
5545
  }
@@ -4285,6 +5694,7 @@ function createWrappedSyncConfig<
4285
5694
  const isCleanedUp = () => startupState.cleanedUp
4286
5695
  type SubsetAcquisition = {
4287
5696
  forwarded: boolean
5697
+ source?: SyncConfigRes
4288
5698
  cancelAdmissionWait?: () => void
4289
5699
  }
4290
5700
  const acquisitions = new Map<LoadSubsetOptions, SubsetAcquisition>()
@@ -4402,21 +5812,27 @@ function createWrappedSyncConfig<
4402
5812
  throw error
4403
5813
  }
4404
5814
  }
4405
- runtime.setSyncControls({
4406
- begin: params.begin,
4407
- write: params.write as SyncControlFns<T, TKey>[`write`],
4408
- commit: params.commit,
4409
- truncate: params.truncate,
4410
- metadata: params.metadata ?? null,
4411
- markError: params.markError,
4412
- })
4413
5815
  runtime.setCollection(
4414
5816
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
5817
+ {
5818
+ begin: params.begin,
5819
+ write: params.write as SyncControlFns<T, TKey>[`write`],
5820
+ commit: params.commit,
5821
+ truncate: params.truncate,
5822
+ metadata: params.metadata ?? null,
5823
+ markError: params.markError,
5824
+ },
4415
5825
  )
4416
5826
 
4417
5827
  const persistenceCapability: SyncPersistenceCapabilityV1<TKey> = {
4418
5828
  protocol: SYNC_PERSISTENCE_PROTOCOL,
4419
5829
  version: SYNC_PERSISTENCE_VERSION,
5830
+ get managedCacheGeneration() {
5831
+ return runtime.hasManagedCacheGeneration()
5832
+ },
5833
+ get startupCacheGenerationRotated() {
5834
+ return runtime.hasRotatedStartupCacheGeneration()
5835
+ },
4420
5836
  reserveCommitTurn: () => {
4421
5837
  const openTransaction = getOpenTransaction()
4422
5838
  if (openTransaction && !openTransaction.internal)
@@ -4430,6 +5846,10 @@ function createWrappedSyncConfig<
4430
5846
  throw runtime.reportSyncError(error)
4431
5847
  }
4432
5848
  },
5849
+ startScopedRecovery: (resetMetadata) =>
5850
+ startupState.cleanedUp
5851
+ ? Promise.resolve()
5852
+ : runtime.startScopedRecovery(resetMetadata),
4433
5853
  scanPersistedRows: (options) =>
4434
5854
  startupState.cleanedUp
4435
5855
  ? Promise.resolve([])
@@ -4459,6 +5879,7 @@ function createWrappedSyncConfig<
4459
5879
  },
4460
5880
  }
4461
5881
 
5882
+ let sourceSession = 0
4462
5883
  const wrappedParams = {
4463
5884
  ...params,
4464
5885
  markError: reportUpstreamError,
@@ -4788,6 +6209,12 @@ function createWrappedSyncConfig<
4788
6209
  settlePublicationAdmissionWaiters(openTransaction, error)
4789
6210
  return createHandledRejection(error)
4790
6211
  }
6212
+ if (runtime.isScopedRecoveryPending()) {
6213
+ settlePendingTransaction(openTransaction)
6214
+ const error = new SyncTransactionAbortedError()
6215
+ settlePublicationAdmissionWaiters(openTransaction, error)
6216
+ return createHandledRejection(error)
6217
+ }
4791
6218
  try {
4792
6219
  bindToCurrentHydration(openTransaction)
4793
6220
  assertHydrationSequenceCurrent(openTransaction)
@@ -4880,9 +6307,183 @@ function createWrappedSyncConfig<
4880
6307
  },
4881
6308
  }
4882
6309
 
6310
+ // Each invocation of a custom source owns its callbacks. Retiring that
6311
+ // invocation before rotation makes late work harmless even when the
6312
+ // source does not provide its own restart hook.
6313
+ const sourceParamsFor = (session: number) => {
6314
+ const active = () =>
6315
+ !startupState.cleanedUp && session === sourceSession
6316
+ const metadata = wrappedParams.metadata
6317
+ return {
6318
+ ...wrappedParams,
6319
+ markReady: () => {
6320
+ if (active()) wrappedParams.markReady()
6321
+ },
6322
+ markError: (error?: unknown) => {
6323
+ if (active()) wrappedParams.markError(error)
6324
+ },
6325
+ begin: () => {
6326
+ if (active()) wrappedParams.begin()
6327
+ },
6328
+ write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
6329
+ if (active()) wrappedParams.write(message)
6330
+ },
6331
+ truncate: () => {
6332
+ if (active()) wrappedParams.truncate()
6333
+ },
6334
+ commit: (signal?: AbortSignal) =>
6335
+ active() ? wrappedParams.commit(signal) : true,
6336
+ metadata: metadata && {
6337
+ ...metadata,
6338
+ row: {
6339
+ get: (key: TKey) =>
6340
+ active() ? metadata.row.get(key) : undefined,
6341
+ set: (key: TKey, value: unknown) => {
6342
+ if (active()) metadata.row.set(key, value)
6343
+ },
6344
+ delete: (key: TKey) => {
6345
+ if (active()) metadata.row.delete(key)
6346
+ },
6347
+ },
6348
+ collection: {
6349
+ get: (key: string) =>
6350
+ active() ? metadata.collection.get(key) : undefined,
6351
+ set: (key: string, value: unknown) => {
6352
+ if (active()) metadata.collection.set(key, value)
6353
+ },
6354
+ delete: (key: string) => {
6355
+ if (active()) metadata.collection.delete(key)
6356
+ },
6357
+ list: (prefix?: string) =>
6358
+ active() ? metadata.collection.list(prefix) : [],
6359
+ },
6360
+ persistence: {
6361
+ ...metadata.persistence,
6362
+ reserveCommitTurn: () => {
6363
+ if (active()) metadata.persistence.reserveCommitTurn()
6364
+ },
6365
+ hydrateBaseline: () =>
6366
+ active()
6367
+ ? metadata.persistence.hydrateBaseline()
6368
+ : Promise.resolve(),
6369
+ startScopedRecovery: (resetMetadata?: {
6370
+ key: string
6371
+ value: unknown
6372
+ }) =>
6373
+ active()
6374
+ ? (metadata.persistence.startScopedRecovery?.(
6375
+ resetMetadata,
6376
+ ) ?? Promise.resolve())
6377
+ : Promise.resolve(),
6378
+ scanPersistedRows: (
6379
+ options?: Parameters<
6380
+ typeof metadata.persistence.scanPersistedRows
6381
+ >[0],
6382
+ ) =>
6383
+ active()
6384
+ ? metadata.persistence.scanPersistedRows(options)
6385
+ : Promise.resolve([]),
6386
+ resumeSnapshot: {
6387
+ certify: () =>
6388
+ active()
6389
+ ? metadata.persistence.resumeSnapshot.certify()
6390
+ : Promise.resolve(),
6391
+ getKeySetEvidence: () =>
6392
+ active()
6393
+ ? metadata.persistence.resumeSnapshot.getKeySetEvidence()
6394
+ : undefined,
6395
+ expectCurrentCommit: () => {
6396
+ if (active())
6397
+ metadata.persistence.resumeSnapshot.expectCurrentCommit()
6398
+ },
6399
+ },
6400
+ },
6401
+ },
6402
+ }
6403
+ }
6404
+
4883
6405
  let sourceResult: SyncConfigRes = {}
4884
6406
  let sourceResultSettled = false
4885
6407
  let activeSourceSyncEntry: Promise<void> | undefined
6408
+ let sourceCleanupPromise: Promise<void> | undefined
6409
+ let customRestartSequence = 0
6410
+ const enterSourceSession = (): void => {
6411
+ const session = ++sourceSession
6412
+ let settleSourceSyncEntry!: () => void
6413
+ activeSourceSyncEntry = new Promise<void>((resolve) => {
6414
+ settleSourceSyncEntry = resolve
6415
+ })
6416
+ try {
6417
+ sourceResult = normalizeSyncFnResult(
6418
+ sourceSyncConfig.sync(
6419
+ runtime.hasManagedCacheGeneration()
6420
+ ? sourceParamsFor(session)
6421
+ : wrappedParams,
6422
+ ),
6423
+ )
6424
+ // A coordinator acquisition owns the source session that accepted
6425
+ // it. A late release must not unload the replacement session.
6426
+ if (!startupState.cleanedUp && sourceResult.loadSubset) {
6427
+ const ownerSource = sourceResult
6428
+ const loadSubset = async (
6429
+ options: TransportedLoadSubsetOptions,
6430
+ ) => {
6431
+ if (startupState.cleanedUp || session !== sourceSession) {
6432
+ throw new Error(`persisted sync source is no longer active`)
6433
+ }
6434
+ await ownerSource.loadSubset?.(
6435
+ options as unknown as LoadSubsetOptions,
6436
+ )
6437
+ }
6438
+ runtime.registerRemoteSubsetOwner(
6439
+ Object.assign(loadSubset, {
6440
+ unloadSubset: (options: TransportedLoadSubsetOptions) =>
6441
+ ownerSource.unloadSubset?.(
6442
+ options as unknown as LoadSubsetOptions,
6443
+ ),
6444
+ onError: (error: unknown) => {
6445
+ if (session === sourceSession) reportUpstreamError(error)
6446
+ },
6447
+ }),
6448
+ )
6449
+ }
6450
+ } finally {
6451
+ activeSourceSyncEntry = undefined
6452
+ settleSourceSyncEntry()
6453
+ }
6454
+ }
6455
+ const restartCustomSource = (cacheRotated: Promise<void>) => {
6456
+ const restartSequence = ++customRestartSequence
6457
+ // Synchronous retirement is required by the recovery boundary. Any
6458
+ // open old-source transaction is abandoned before the new cache binds.
6459
+ if (!sourceCleanupPromise) {
6460
+ sourceSession++
6461
+ const abortError = new SyncTransactionAbortedError()
6462
+ for (const transaction of transactionStack) {
6463
+ settlePublicationAdmissionWaiters(transaction, abortError)
6464
+ }
6465
+ transactionStack.length = 0
6466
+ const retiredResult = sourceResult
6467
+ sourceCleanupPromise = Promise.resolve().then(() =>
6468
+ retiredResult.cleanup?.(),
6469
+ )
6470
+ }
6471
+ return Promise.all([sourceCleanupPromise, cacheRotated]).then(() => {
6472
+ if (
6473
+ startupState.cleanedUp ||
6474
+ restartSequence !== customRestartSequence
6475
+ )
6476
+ return
6477
+ sourceCleanupPromise = undefined
6478
+ enterSourceSession()
6479
+ runtime.setSourceRestartAfterScopedRecovery(
6480
+ sourceResult.restartAfterScopedRecovery ??
6481
+ (runtime.hasManagedCacheGeneration()
6482
+ ? restartCustomSource
6483
+ : undefined),
6484
+ )
6485
+ })
6486
+ }
4886
6487
  fullStartPromise = runtime.ensureStarted()
4887
6488
  // Startup can fail before the source reaches markReady or loadSubset,
4888
6489
  // which are the two eventual consumers of this outer adopting promise.
@@ -4898,40 +6499,14 @@ function createWrappedSyncConfig<
4898
6499
  return sourceResult
4899
6500
  }
4900
6501
 
4901
- let settleSourceSyncEntry!: () => void
4902
- activeSourceSyncEntry = new Promise<void>((resolve) => {
4903
- settleSourceSyncEntry = resolve
4904
- })
4905
- try {
4906
- sourceResult = normalizeSyncFnResult(
4907
- sourceSyncConfig.sync(wrappedParams),
4908
- )
4909
- } finally {
4910
- activeSourceSyncEntry = undefined
4911
- settleSourceSyncEntry()
4912
- }
4913
- // Cleanup can reenter while the source sync function is still active.
4914
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
4915
- if (!startupState.cleanedUp && sourceResult.loadSubset) {
4916
- const loadSubset = async (options: TransportedLoadSubsetOptions) => {
4917
- if (startupState.cleanedUp) {
4918
- throw new Error(`persisted sync source is no longer active`)
4919
- }
4920
- await sourceResult.loadSubset?.(
4921
- options as unknown as LoadSubsetOptions,
4922
- )
4923
- }
4924
- runtime.registerRemoteSubsetOwner(
4925
- Object.assign(loadSubset, {
4926
- unloadSubset: (options: TransportedLoadSubsetOptions) =>
4927
- sourceResult.unloadSubset?.(
4928
- options as unknown as LoadSubsetOptions,
4929
- ),
4930
- onError: reportUpstreamError,
4931
- }),
4932
- )
4933
- }
6502
+ enterSourceSession()
4934
6503
  sourceResultSettled = true
6504
+ runtime.setSourceRestartAfterScopedRecovery(
6505
+ sourceResult.restartAfterScopedRecovery ??
6506
+ (runtime.hasManagedCacheGeneration()
6507
+ ? restartCustomSource
6508
+ : undefined),
6509
+ )
4935
6510
  return sourceResult
4936
6511
  })()
4937
6512
  resolveSourceResultAssigned()
@@ -4942,6 +6517,7 @@ function createWrappedSyncConfig<
4942
6517
  return {
4943
6518
  cleanup: async () => {
4944
6519
  startupState.cleanedUp = true
6520
+ runtime.clearSourceRestartAfterScopedRecovery()
4945
6521
  // The optional source starts behind persistence metadata. If it
4946
6522
  // reenters cleanup before returning, wait for that exact synchronous
4947
6523
  // entry to publish its cleanup callback before sampling the result.
@@ -4958,9 +6534,11 @@ function createWrappedSyncConfig<
4958
6534
  transactionStack.length = 0
4959
6535
  let sourceCleanup: Promise<void>
4960
6536
  try {
4961
- sourceCleanup = sourceSyncEntry
4962
- ? sourceSyncEntry.then(() => sourceResult.cleanup?.())
4963
- : Promise.resolve(sourceResult.cleanup?.())
6537
+ sourceCleanup =
6538
+ sourceCleanupPromise ??
6539
+ (sourceSyncEntry
6540
+ ? sourceSyncEntry.then(() => sourceResult.cleanup?.())
6541
+ : Promise.resolve(sourceResult.cleanup?.()))
4964
6542
  } catch (error) {
4965
6543
  sourceCleanup = Promise.reject(error)
4966
6544
  }
@@ -4988,10 +6566,9 @@ function createWrappedSyncConfig<
4988
6566
  if (runtimeOutcome.status === `rejected`) throw runtimeOutcome.reason
4989
6567
  },
4990
6568
  loadSubset: (options: LoadSubsetOptions): true | Promise<void> => {
4991
- const acquisition = { forwarded: false }
6569
+ const acquisition: SubsetAcquisition = { forwarded: false }
4992
6570
  acquisitions.set(options, acquisition)
4993
6571
  const forwardUpstream = (
4994
- resolvedSourceResult: SyncConfigRes,
4995
6572
  loadOptions: LoadSubsetOptions,
4996
6573
  ): true | Promise<void> => {
4997
6574
  // Hydration is another async boundary. A release before this
@@ -5002,14 +6579,17 @@ function createWrappedSyncConfig<
5002
6579
  ) {
5003
6580
  return true
5004
6581
  }
5005
- if (!resolvedSourceResult.loadSubset) return true
6582
+ const currentSource = sourceResult
6583
+ if (!currentSource.loadSubset) return true
5006
6584
  acquisition.forwarded = true
6585
+ acquisition.source = currentSource
5007
6586
  try {
5008
6587
  // Returning a promise transfers its lease even if it rejects.
5009
6588
  // Only a synchronous throw leaves no upstream lease to release.
5010
- return resolvedSourceResult.loadSubset(loadOptions)
6589
+ return currentSource.loadSubset(loadOptions)
5011
6590
  } catch (error) {
5012
6591
  acquisition.forwarded = false
6592
+ acquisition.source = undefined
5013
6593
  throw error
5014
6594
  }
5015
6595
  }
@@ -5017,19 +6597,60 @@ function createWrappedSyncConfig<
5017
6597
  const openTransaction = getOpenTransaction()
5018
6598
  const needsPublicationAdmission =
5019
6599
  openTransaction?.reservesCommitTurn === true
6600
+ let hydrationStarted = false
6601
+ const raceRetainedLoad = (
6602
+ work: Promise<void>,
6603
+ canAbortPromptly = () => true,
6604
+ ): Promise<void> => {
6605
+ const signal = options.signal
6606
+ const abortable = !signal
6607
+ ? work
6608
+ : new Promise<void>((resolve, reject) => {
6609
+ const abortError = () =>
6610
+ signal.reason ?? new SyncTransactionAbortedError()
6611
+ const onAbort = () => {
6612
+ // Once an uncancelable baseline read begins, its returned
6613
+ // load keeps core's overlapping replay private until the
6614
+ // resulting rows and receipts become visible.
6615
+ if (canAbortPromptly()) reject(abortError())
6616
+ }
6617
+ signal.addEventListener(`abort`, onAbort, { once: true })
6618
+ if (signal.aborted) onAbort()
6619
+ void work.then(
6620
+ () => {
6621
+ signal.removeEventListener(`abort`, onAbort)
6622
+ if (signal.aborted) reject(abortError())
6623
+ else resolve()
6624
+ },
6625
+ (error: unknown) => {
6626
+ signal.removeEventListener(`abort`, onAbort)
6627
+ reject(signal.aborted ? abortError() : error)
6628
+ },
6629
+ )
6630
+ })
6631
+ return abortable.catch((error: unknown) => {
6632
+ if (
6633
+ startupState.cleanedUp ||
6634
+ acquisitions.get(options) !== acquisition
6635
+ ) {
6636
+ return
6637
+ }
6638
+ throw error
6639
+ })
6640
+ }
5020
6641
  const hydrated =
5021
6642
  sourceResultSettled && !needsPublicationAdmission
5022
6643
  ? runtime.loadHydratedSubset(options, (loadOptions) =>
5023
- forwardUpstream(sourceResult, loadOptions),
6644
+ forwardUpstream(loadOptions),
5024
6645
  )
5025
6646
  : undefined
5026
6647
  if (hydrated !== undefined) {
5027
- return hydrated
6648
+ return hydrated === true ? true : raceRetainedLoad(hydrated)
5028
6649
  }
5029
6650
 
5030
- return (async () => {
6651
+ const work = (async () => {
5031
6652
  await fullStartPromise
5032
- const resolvedSourceResult = await sourceResultPromise
6653
+ await sourceResultPromise
5033
6654
  if (
5034
6655
  startupState.cleanedUp ||
5035
6656
  acquisitions.get(options) !== acquisition
@@ -5052,10 +6673,18 @@ function createWrappedSyncConfig<
5052
6673
  ) {
5053
6674
  return
5054
6675
  }
5055
- return runtime.loadSubset(options, (loadOptions) =>
5056
- forwardUpstream(resolvedSourceResult, loadOptions),
6676
+ return runtime.loadSubset(
6677
+ options,
6678
+ (loadOptions) => forwardUpstream(loadOptions),
6679
+ () => {
6680
+ hydrationStarted = true
6681
+ },
6682
+ () =>
6683
+ !startupState.cleanedUp &&
6684
+ acquisitions.get(options) === acquisition,
5057
6685
  )
5058
6686
  })()
6687
+ return raceRetainedLoad(work, () => !hydrationStarted)
5059
6688
  },
5060
6689
  unloadSubset: (options: LoadSubsetOptions) => {
5061
6690
  const acquisition = acquisitions.get(options)
@@ -5063,7 +6692,9 @@ function createWrappedSyncConfig<
5063
6692
  acquisitions.delete(options)
5064
6693
  runtime.unloadSubset(
5065
6694
  options,
5066
- acquisition?.forwarded ? sourceResult.unloadSubset : undefined,
6695
+ acquisition?.forwarded
6696
+ ? acquisition.source?.unloadSubset
6697
+ : undefined,
5067
6698
  )
5068
6699
  },
5069
6700
  }
@@ -5077,16 +6708,16 @@ function createLoopbackSyncConfig<
5077
6708
  >(runtime: PersistedCollectionRuntime<T, TKey>): SyncConfig<T, TKey> {
5078
6709
  return {
5079
6710
  sync: (params) => {
5080
- runtime.setSyncControls({
5081
- begin: params.begin,
5082
- write: params.write as SyncControlFns<T, TKey>[`write`],
5083
- commit: params.commit,
5084
- truncate: params.truncate,
5085
- metadata: params.metadata ?? null,
5086
- markError: params.markError,
5087
- })
5088
6711
  runtime.setCollection(
5089
6712
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
6713
+ {
6714
+ begin: params.begin,
6715
+ write: params.write as SyncControlFns<T, TKey>[`write`],
6716
+ commit: params.commit,
6717
+ truncate: params.truncate,
6718
+ metadata: params.metadata ?? null,
6719
+ markError: params.markError,
6720
+ },
5090
6721
  )
5091
6722
 
5092
6723
  void runtime
@@ -5206,6 +6837,20 @@ export function persistedCollectionOptions<
5206
6837
  ? new PersistedReadinessTracker(networkTimeoutMs)
5207
6838
  : undefined
5208
6839
 
6840
+ let claimed = false
6841
+ let claimedRuntime: PersistedCollectionRuntime<T, TKey> | undefined
6842
+ const claimOptions = () => {
6843
+ if (claimed) {
6844
+ throw new InvalidPersistedCollectionConfigError(
6845
+ `Persisted options can create only one Collection. Create fresh options for each Collection.`,
6846
+ )
6847
+ }
6848
+ claimed = true
6849
+ return () => {
6850
+ if (!claimedRuntime?.hasBoundCollection()) claimed = false
6851
+ }
6852
+ }
6853
+
5209
6854
  if (hasOwnSyncKey(options)) {
5210
6855
  if (!isValidSyncConfig(options.sync)) {
5211
6856
  throw new InvalidSyncConfigError(
@@ -5236,7 +6881,9 @@ export function persistedCollectionOptions<
5236
6881
  syncOptions.syncMode ?? `eager`,
5237
6882
  collectionId,
5238
6883
  persistedReadinessTracker,
6884
+ claimOptions,
5239
6885
  )
6886
+ claimedRuntime = runtime
5240
6887
 
5241
6888
  const result = {
5242
6889
  ...syncOptions,
@@ -5259,6 +6906,7 @@ export function persistedCollectionOptions<
5259
6906
  ...options,
5260
6907
  id: collectionId,
5261
6908
  } as never) as typeof result,
6909
+ claimOptions,
5262
6910
  )
5263
6911
  }
5264
6912
 
@@ -5284,7 +6932,9 @@ export function persistedCollectionOptions<
5284
6932
  localOnlyOptions.syncMode ?? `eager`,
5285
6933
  localOnlyOptions.id ?? DEFAULT_DB_NAME,
5286
6934
  persistedReadinessTracker,
6935
+ claimOptions,
5287
6936
  )
6937
+ claimedRuntime = runtime
5288
6938
 
5289
6939
  const wrappedOnInsert = async (
5290
6940
  params: InsertMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
@@ -5381,6 +7031,7 @@ export function persistedCollectionOptions<
5381
7031
  ...options,
5382
7032
  id: collectionId,
5383
7033
  } as never) as typeof result,
7034
+ claimOptions,
5384
7035
  )
5385
7036
  }
5386
7037