@tanstack/db-sqlite-persistence-core 0.4.7 → 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.
Files changed (40) hide show
  1. package/README.md +32 -3
  2. package/dist/cjs/broadcast-coordinator.cjs +282 -43
  3. package/dist/cjs/broadcast-coordinator.cjs.map +1 -1
  4. package/dist/cjs/broadcast-coordinator.d.cts +25 -8
  5. package/dist/cjs/committed-tx-anchor.cjs +9 -0
  6. package/dist/cjs/committed-tx-anchor.cjs.map +1 -0
  7. package/dist/cjs/committed-tx-anchor.d.cts +1 -0
  8. package/dist/cjs/index.cjs +4 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/persisted.cjs +1599 -313
  11. package/dist/cjs/persisted.cjs.map +1 -1
  12. package/dist/cjs/persisted.d.cts +106 -13
  13. package/dist/cjs/remote-subset-wire.cjs +11 -0
  14. package/dist/cjs/remote-subset-wire.cjs.map +1 -1
  15. package/dist/cjs/remote-subset-wire.d.cts +1 -0
  16. package/dist/cjs/sqlite-core-adapter.cjs +705 -64
  17. package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
  18. package/dist/cjs/sqlite-core-adapter.d.cts +53 -10
  19. package/dist/esm/broadcast-coordinator.d.ts +25 -8
  20. package/dist/esm/broadcast-coordinator.js +283 -44
  21. package/dist/esm/broadcast-coordinator.js.map +1 -1
  22. package/dist/esm/committed-tx-anchor.d.ts +1 -0
  23. package/dist/esm/committed-tx-anchor.js +9 -0
  24. package/dist/esm/committed-tx-anchor.js.map +1 -0
  25. package/dist/esm/index.js +6 -2
  26. package/dist/esm/persisted.d.ts +106 -13
  27. package/dist/esm/persisted.js +1601 -315
  28. package/dist/esm/persisted.js.map +1 -1
  29. package/dist/esm/remote-subset-wire.d.ts +1 -0
  30. package/dist/esm/remote-subset-wire.js +11 -0
  31. package/dist/esm/remote-subset-wire.js.map +1 -1
  32. package/dist/esm/sqlite-core-adapter.d.ts +53 -10
  33. package/dist/esm/sqlite-core-adapter.js +708 -67
  34. package/dist/esm/sqlite-core-adapter.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/broadcast-coordinator.ts +415 -62
  37. package/src/committed-tx-anchor.ts +13 -0
  38. package/src/persisted.ts +2283 -370
  39. package/src/remote-subset-wire.ts +12 -0
  40. package/src/sqlite-core-adapter.ts +891 -68
@@ -1,7 +1,10 @@
1
1
  import { safeRandomUUID } from '@tanstack/db'
2
+ import { isValidCommittedTxAnchor } from './committed-tx-anchor'
2
3
  import {
3
4
  DuplicateRemoteSubsetOwnerError,
4
5
  IndeterminateCommitError,
6
+ InvalidPersistedCollectionConfigError,
7
+ InvalidPersistenceAdapterError,
5
8
  PersistedCollectionDurabilityError,
6
9
  RetryableRemoteSubsetAcquisitionError,
7
10
  toPersistedCollectionDurabilityError,
@@ -11,6 +14,7 @@ import {
11
14
  toTransportedLoadSubsetOptions,
12
15
  } from './remote-subset-wire'
13
16
  import { unloadRemoteSubsetOwner } from './remote-subset-owner'
17
+ import { eagerStorageTarget, managedStorageTarget } from './persisted'
14
18
  import type { LoadSubsetOptions } from '@tanstack/db'
15
19
  import type { IndeterminateCommitRequestType } from './errors'
16
20
  import type { TransportedLoadSubsetOptions } from './remote-subset-wire'
@@ -18,6 +22,7 @@ import type { RemoteSubsetOwner } from './remote-subset-owner'
18
22
  import type {
19
23
  ApplyCommittedTxResponse,
20
24
  ApplyLocalMutationsResponse,
25
+ CommittedTxAnchor,
21
26
  EnsureRemoteSubsetRequest,
22
27
  EnsureRemoteSubsetResponse,
23
28
  HydrationPersistenceAdapter,
@@ -25,10 +30,12 @@ import type {
25
30
  PersistedIndexSpec,
26
31
  PersistedMutationEnvelope,
27
32
  PersistedRowMetadataMutation,
33
+ PersistedStorageTarget,
28
34
  PersistedTx,
29
35
  PersistenceAdapter,
30
36
  ProtocolEnvelope,
31
37
  PullSinceResponse,
38
+ ReconcileCommittedTxResponse,
32
39
  ReleaseRemoteSubsetRequest,
33
40
  ReleaseRemoteSubsetResponse,
34
41
  TxCommitted,
@@ -60,12 +67,14 @@ type RPCRequest =
60
67
  rpcId: string
61
68
  signature: string
62
69
  spec: PersistedIndexSpec
70
+ cacheGenerationClaimId?: string
63
71
  }
64
72
  | {
65
73
  type: `rpc:applyLocalMutations:req`
66
74
  rpcId: string
67
75
  envelopeId: string
68
76
  mutations: Array<PersistedMutationEnvelope>
77
+ cacheGenerationClaimId?: string
69
78
  }
70
79
  | {
71
80
  type: `rpc:applyCommittedTx:req`
@@ -73,10 +82,17 @@ type RPCRequest =
73
82
  envelopeId: string
74
83
  tx: PersistedTx
75
84
  }
85
+ | {
86
+ type: `rpc:reconcileCommittedTx:req`
87
+ rpcId: string
88
+ tx: PersistedTx
89
+ anchor: CommittedTxAnchor
90
+ }
76
91
  | {
77
92
  type: `rpc:pullSince:req`
78
93
  rpcId: string
79
94
  fromRowVersion: number
95
+ cacheGenerationClaimId?: string
80
96
  }
81
97
 
82
98
  type RPCResponse =
@@ -90,6 +106,7 @@ type RPCResponse =
90
106
  }
91
107
  | ApplyLocalMutationsResponse
92
108
  | ApplyCommittedTxResponse
109
+ | ReconcileCommittedTxResponse
93
110
  | PullSinceResponse
94
111
 
95
112
  type PendingRPC = {
@@ -115,11 +132,16 @@ type CollectionState = {
115
132
  // belong to the persisted sync wrapper, not the elected writer transport.
116
133
  type CoordinatorAdapter = Pick<
117
134
  PersistenceAdapter,
118
- `loadSubset` | `applyCommittedTx` | `ensureIndex` | `runInRegularScope`
135
+ | `loadSubset`
136
+ | `applyCommittedTx`
137
+ | `reconcileCommittedTx`
138
+ | `ensureIndex`
139
+ | `runInRegularScope`
119
140
  > & {
120
141
  pullSince?: (
121
- collectionId: string,
142
+ target: PersistedStorageTarget,
122
143
  fromRowVersion: number,
144
+ ctx?: { cacheGenerationClaimId?: string },
123
145
  ) => Promise<
124
146
  | {
125
147
  latestRowVersion: number
@@ -132,13 +154,31 @@ type CoordinatorAdapter = Pick<
132
154
  deletedKeys: Array<string | number>
133
155
  }
134
156
  >
135
- getStreamPosition?: (collectionId: string) => Promise<{
157
+ getStreamPosition?: (
158
+ target: PersistedStorageTarget,
159
+ ctx?: { cacheGenerationClaimId?: string },
160
+ ) => Promise<{
161
+ latestTerm: number
162
+ latestSeq: number
163
+ latestRowVersion: number
164
+ }>
165
+ reserveLeadershipTerm?: (
166
+ target: PersistedStorageTarget,
167
+ observedTerm: number,
168
+ ctx?: { cacheGenerationClaimId?: string },
169
+ ) => Promise<{
136
170
  latestTerm: number
137
171
  latestSeq: number
138
172
  latestRowVersion: number
139
173
  }>
140
174
  }
141
175
 
176
+ function requireDurableElection(adapter: CoordinatorAdapter): void {
177
+ if (typeof adapter.reserveLeadershipTerm !== `function`) {
178
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`)
179
+ }
180
+ }
181
+
142
182
  type ActiveRemoteSubsetAcquisition = {
143
183
  collectionId: string
144
184
  requesterId: string
@@ -146,6 +186,7 @@ type ActiveRemoteSubsetAcquisition = {
146
186
  owner: RemoteSubsetOwner
147
187
  options: TransportedLoadSubsetOptions
148
188
  load: Promise<void>
189
+ admitted: Promise<void>
149
190
  transferred: boolean
150
191
  released: boolean
151
192
  terminalRelease: boolean
@@ -230,6 +271,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
230
271
  private readonly coordinatorName: string
231
272
  private defaultAdapter: CoordinatorAdapter | null
232
273
  private readonly collectionAdapters = new Map<string, CoordinatorAdapter>()
274
+ private readonly collectionClaimIds = new Map<string, string>()
233
275
  private readonly remoteSubsetOwners = new Map<string, RemoteSubsetOwner>()
234
276
  private readonly remoteSubsetIds = new Map<
235
277
  string,
@@ -282,6 +324,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
282
324
  }
283
325
 
284
326
  constructor(options: BroadcastCollectionCoordinatorOptions) {
327
+ if (options.adapter) requireDurableElection(options.adapter)
285
328
  this.dbName = options.dbName
286
329
  this.coordinatorName = options.coordinatorName
287
330
  this.defaultAdapter = options.adapter ?? null
@@ -300,6 +343,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
300
343
  * Called by a host persistence factory to wire the internally-created adapter.
301
344
  */
302
345
  setAdapter(adapter: CoordinatorAdapter): void {
346
+ requireDurableElection(adapter)
303
347
  this.defaultAdapter = adapter
304
348
  }
305
349
 
@@ -307,8 +351,15 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
307
351
  setAdapterForCollection(
308
352
  collectionId: string,
309
353
  adapter: CoordinatorAdapter,
354
+ cacheGenerationClaimId?: string,
310
355
  ): void {
356
+ requireDurableElection(adapter)
311
357
  this.collectionAdapters.set(collectionId, adapter)
358
+ if (cacheGenerationClaimId) {
359
+ this.collectionClaimIds.set(collectionId, cacheGenerationClaimId)
360
+ } else {
361
+ this.collectionClaimIds.delete(collectionId)
362
+ }
312
363
  }
313
364
 
314
365
  registerRemoteSubsetOwner(
@@ -491,11 +542,38 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
491
542
  }
492
543
  try {
493
544
  await acquisition.release
545
+ } catch (error) {
546
+ this.scheduleRemoteSubsetReleaseRetry(acquisition, options)
547
+ throw error
494
548
  } finally {
495
549
  acquisition.release = null
496
550
  }
497
551
  }
498
552
 
553
+ private scheduleRemoteSubsetReleaseRetry(
554
+ acquisition: OutboundRemoteSubsetAcquisition,
555
+ options: LoadSubsetOptions,
556
+ ): void {
557
+ const key = remoteSubsetAcquisitionKey(
558
+ acquisition.collectionId,
559
+ acquisition.acquisitionId,
560
+ )
561
+ if (
562
+ this.isDisposed() ||
563
+ this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition ||
564
+ acquisition.retryTimer !== null
565
+ ) {
566
+ return
567
+ }
568
+ acquisition.retryTimer = setTimeout(() => {
569
+ acquisition.retryTimer = null
570
+ void this.requestReleaseRemoteSubset(
571
+ acquisition.collectionId,
572
+ options,
573
+ ).catch(() => undefined)
574
+ }, RPC_RETRY_DELAY_MS)
575
+ }
576
+
499
577
  private async acquireRemoteSubset(
500
578
  acquisition: OutboundRemoteSubsetAcquisition,
501
579
  ): Promise<void> {
@@ -571,7 +649,11 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
571
649
  try {
572
650
  await work
573
651
  acquired = true
574
- this.cancelRemoteSubsetReplayRetry(acquisition)
652
+ // Release may have failed while this acquisition was in flight. In that
653
+ // case the shared timer carries release retry debt, not replay work.
654
+ if (!acquisition.releaseRequested) {
655
+ this.cancelRemoteSubsetReplayRetry(acquisition)
656
+ }
575
657
  } finally {
576
658
  if (acquisition.inFlight === work) acquisition.inFlight = null
577
659
  const current = this.collections.get(acquisition.collectionId)
@@ -604,14 +686,27 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
604
686
  spec: PersistedIndexSpec,
605
687
  scopedAdapter?: HydrationPersistenceAdapter,
606
688
  localEnsureCompleted = false,
689
+ cacheGenerationClaimId?: string,
607
690
  ): Promise<void> {
608
691
  if (this.isLeader(collectionId)) {
609
692
  if (localEnsureCompleted) return
610
- await (scopedAdapter ?? this.requireAdapter(collectionId)).ensureIndex(
611
- collectionId,
612
- signature,
613
- spec,
614
- )
693
+ const adapter = scopedAdapter ?? this.requireAdapter(collectionId)
694
+ if (cacheGenerationClaimId) {
695
+ await adapter.ensureIndex(
696
+ managedStorageTarget(collectionId, cacheGenerationClaimId),
697
+ signature,
698
+ spec,
699
+ {
700
+ cacheGenerationClaimId,
701
+ },
702
+ )
703
+ } else {
704
+ await adapter.ensureIndex(
705
+ eagerStorageTarget(collectionId),
706
+ signature,
707
+ spec,
708
+ )
709
+ }
615
710
  return
616
711
  }
617
712
 
@@ -625,6 +720,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
625
720
  rpcId: safeRandomUUID(),
626
721
  signature,
627
722
  spec,
723
+ cacheGenerationClaimId,
628
724
  })
629
725
 
630
726
  if (!response.ok) {
@@ -637,6 +733,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
637
733
  async requestApplyLocalMutations(
638
734
  collectionId: string,
639
735
  mutations: Array<PersistedMutationEnvelope>,
736
+ cacheGenerationClaimId?: string,
640
737
  ): Promise<ApplyLocalMutationsResponse> {
641
738
  const initialRoute = this.waitForInitialRoute(collectionId)
642
739
  if (initialRoute) await initialRoute
@@ -646,6 +743,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
646
743
  rpcId: safeRandomUUID(),
647
744
  envelopeId: safeRandomUUID(),
648
745
  mutations,
746
+ cacheGenerationClaimId,
649
747
  })
650
748
  }
651
749
 
@@ -654,6 +752,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
654
752
  rpcId: safeRandomUUID(),
655
753
  envelopeId: safeRandomUUID(),
656
754
  mutations,
755
+ cacheGenerationClaimId,
657
756
  })
658
757
  }
659
758
 
@@ -677,10 +776,48 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
677
776
  return this.sendRPC<ApplyCommittedTxResponse>(collectionId, request)
678
777
  }
679
778
 
779
+ async reconcileCommittedTx(
780
+ collectionId: string,
781
+ tx: PersistedTx,
782
+ anchor: CommittedTxAnchor,
783
+ scopedAdapter?: HydrationPersistenceAdapter,
784
+ ): Promise<ReconcileCommittedTxResponse> {
785
+ const initialRoute = this.waitForInitialRoute(collectionId)
786
+ if (initialRoute) await initialRoute
787
+ // Each attempt checks the exact txId under the elected writer lock, so a
788
+ // stale NOT_LEADER reply can follow the replacement route safely.
789
+ let response: ReconcileCommittedTxResponse | undefined
790
+ for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
791
+ if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt)
792
+ const request: Extract<
793
+ RPCRequest,
794
+ { type: `rpc:reconcileCommittedTx:req` }
795
+ > = {
796
+ type: `rpc:reconcileCommittedTx:req`,
797
+ rpcId: safeRandomUUID(),
798
+ tx,
799
+ anchor,
800
+ }
801
+ response = this.isLeader(collectionId)
802
+ ? await this.handleReconcileCommittedTx(
803
+ collectionId,
804
+ request,
805
+ scopedAdapter,
806
+ )
807
+ : await this.sendRPC<ReconcileCommittedTxResponse>(
808
+ collectionId,
809
+ request,
810
+ )
811
+ if (response.ok || response.code !== `NOT_LEADER`) return response
812
+ }
813
+ return response!
814
+ }
815
+
680
816
  async pullSince(
681
817
  collectionId: string,
682
818
  fromRowVersion: number,
683
819
  scopedAdapter?: HydrationPersistenceAdapter,
820
+ cacheGenerationClaimId?: string,
684
821
  ): Promise<PullSinceResponse> {
685
822
  if (this.isLeader(collectionId)) {
686
823
  return this.handlePullSince(
@@ -689,6 +826,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
689
826
  type: `rpc:pullSince:req`,
690
827
  rpcId: safeRandomUUID(),
691
828
  fromRowVersion,
829
+ cacheGenerationClaimId,
692
830
  },
693
831
  scopedAdapter,
694
832
  )
@@ -698,6 +836,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
698
836
  type: `rpc:pullSince:req`,
699
837
  rpcId: safeRandomUUID(),
700
838
  fromRowVersion,
839
+ cacheGenerationClaimId,
701
840
  })
702
841
  }
703
842
 
@@ -736,6 +875,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
736
875
  this.channel.close()
737
876
  this.collections.clear()
738
877
  this.collectionAdapters.clear()
878
+ this.collectionClaimIds.clear()
739
879
  for (const collectionId of this.remoteSubsetOwners.keys()) {
740
880
  this.releaseInboundRemoteSubsetAcquisitions(collectionId)
741
881
  }
@@ -806,14 +946,28 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
806
946
  }
807
947
 
808
948
  try {
809
- // Restore stream position from DB before claiming leadership
949
+ // Reserve an election term durably before advertising this route.
950
+ // Election bypasses the shared scheduler because a hydrate may be
951
+ // waiting for its first writer route. The writer Web Lock orders
952
+ // reservation after any old owner's in-flight durable write.
810
953
  const adapter = this.requireAdapter(collectionId)
811
- if (adapter.getStreamPosition) {
812
- const pos = await adapter.getStreamPosition(collectionId)
813
- state.latestTerm = pos.latestTerm
814
- state.latestSeq = pos.latestSeq
815
- state.latestRowVersion = pos.latestRowVersion
816
- }
954
+ const reserveTerm = adapter.reserveLeadershipTerm
955
+ if (!reserveTerm)
956
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`)
957
+ const pos = await this.withWriterLock(() => {
958
+ const claimId = this.collectionClaimIds.get(collectionId)
959
+ return reserveTerm.call(
960
+ adapter,
961
+ claimId
962
+ ? managedStorageTarget(collectionId, claimId)
963
+ : eagerStorageTarget(collectionId),
964
+ state.latestTerm,
965
+ { cacheGenerationClaimId: claimId },
966
+ )
967
+ })
968
+ state.latestTerm = pos.latestTerm
969
+ state.latestSeq = pos.latestSeq
970
+ state.latestRowVersion = pos.latestRowVersion
817
971
 
818
972
  if (
819
973
  this.isDisposed() ||
@@ -825,7 +979,6 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
825
979
  return
826
980
  }
827
981
 
828
- state.latestTerm++
829
982
  state.isLeader = true
830
983
  state.leaderId = this.nodeId
831
984
  this.notifyRouteWaiters(state)
@@ -1005,6 +1158,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1005
1158
  this.releaseLeadership(collectionId, state)
1006
1159
  this.collections.delete(collectionId)
1007
1160
  this.collectionAdapters.delete(collectionId)
1161
+ this.collectionClaimIds.delete(collectionId)
1008
1162
  const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`
1009
1163
  for (const key of this.appliedEnvelopes.keys()) {
1010
1164
  if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key)
@@ -1175,6 +1329,9 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1175
1329
  typeof heartbeat.latestRowVersion === `number`
1176
1330
  ) {
1177
1331
  const state = this.getOrCreateCollectionState(envelope.collectionId)
1332
+ // The local Web Lock owns this route even if an older heartbeat arrives
1333
+ // while leadership changes.
1334
+ if (state.isLeader) return
1178
1335
  if (heartbeat.term < state.latestTerm) return
1179
1336
  const changedLeader = state.leaderId !== heartbeat.leaderId
1180
1337
  state.leaderId = heartbeat.leaderId
@@ -1415,6 +1572,8 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1415
1572
  return this.handleApplyLocalMutations(collectionId, request)
1416
1573
  case `rpc:applyCommittedTx:req`:
1417
1574
  return this.handleApplyCommittedTx(collectionId, request)
1575
+ case `rpc:reconcileCommittedTx:req`:
1576
+ return this.handleReconcileCommittedTx(collectionId, request)
1418
1577
  case `rpc:pullSince:req`:
1419
1578
  return this.handlePullSince(collectionId, request)
1420
1579
  }
@@ -1546,12 +1705,11 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1546
1705
  acquisition.released = true
1547
1706
  acquisition.release = (async () => {
1548
1707
  try {
1549
- await acquisition.load
1550
- } catch {
1551
- // A returned promise transfers the lease even when initial loading fails.
1552
- }
1553
- try {
1708
+ await acquisition.admitted
1554
1709
  if (acquisition.transferred) {
1710
+ // The owner may require unload to settle its load. Invocation of the
1711
+ // owner already transferred this lease; source settlement is not a
1712
+ // prerequisite for giving it the cancellation opportunity.
1555
1713
  await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options)
1556
1714
  }
1557
1715
  } finally {
@@ -1711,6 +1869,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1711
1869
  owner: options.owner,
1712
1870
  options: options.options,
1713
1871
  load: Promise.resolve(),
1872
+ admitted: Promise.resolve(),
1714
1873
  transferred: false,
1715
1874
  released: false,
1716
1875
  terminalRelease: false,
@@ -1718,6 +1877,10 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1718
1877
  }
1719
1878
  this.releasedRemoteSubsetAcquisitionTimes.delete(key)
1720
1879
  this.inboundRemoteSubsetAcquisitions.set(key, acquisition)
1880
+ let resolveAdmission!: () => void
1881
+ acquisition.admitted = new Promise<void>((resolve) => {
1882
+ resolveAdmission = resolve
1883
+ })
1721
1884
  let resolveLoad!: () => void
1722
1885
  let rejectLoad!: (error: unknown) => void
1723
1886
  acquisition.load = new Promise<void>((resolve, reject) => {
@@ -1730,6 +1893,8 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1730
1893
  void Promise.resolve(load).then(resolveLoad, rejectLoad)
1731
1894
  } catch (error) {
1732
1895
  rejectLoad(error)
1896
+ } finally {
1897
+ resolveAdmission()
1733
1898
  }
1734
1899
 
1735
1900
  try {
@@ -1765,11 +1930,26 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1765
1930
  rpcId: string
1766
1931
  signature: string
1767
1932
  spec: PersistedIndexSpec
1933
+ cacheGenerationClaimId?: string
1768
1934
  },
1769
1935
  ): Promise<RPCResponse> {
1770
- await this.withScheduledWriterLock(collectionId, (adapter) =>
1771
- adapter.ensureIndex(collectionId, request.signature, request.spec),
1772
- )
1936
+ await this.withScheduledWriterLock(collectionId, (adapter) => {
1937
+ if (request.cacheGenerationClaimId) {
1938
+ return adapter.ensureIndex(
1939
+ managedStorageTarget(collectionId, request.cacheGenerationClaimId),
1940
+ request.signature,
1941
+ request.spec,
1942
+ {
1943
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
1944
+ },
1945
+ )
1946
+ }
1947
+ return adapter.ensureIndex(
1948
+ eagerStorageTarget(collectionId),
1949
+ request.signature,
1950
+ request.spec,
1951
+ )
1952
+ })
1773
1953
  return {
1774
1954
  type: `rpc:ensurePersistedIndex:res`,
1775
1955
  rpcId: request.rpcId,
@@ -1784,6 +1964,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1784
1964
  rpcId: string
1785
1965
  envelopeId: string
1786
1966
  mutations: Array<PersistedMutationEnvelope>
1967
+ cacheGenerationClaimId?: string
1787
1968
  },
1788
1969
  ): Promise<ApplyLocalMutationsResponse> {
1789
1970
  const envelopeKey = appliedEnvelopeKey(collectionId, request.envelopeId)
@@ -1817,6 +1998,14 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1817
1998
  collectionId: string,
1818
1999
  request: Extract<RPCRequest, { type: `rpc:applyLocalMutations:req` }>,
1819
2000
  ): Promise<ApplyLocalMutationsResponse> {
2001
+ if (
2002
+ this.collectionClaimIds.has(collectionId) &&
2003
+ !request.cacheGenerationClaimId
2004
+ ) {
2005
+ throw new InvalidPersistedCollectionConfigError(
2006
+ `A managed local mutation requires a persisted cache claim`,
2007
+ )
2008
+ }
1820
2009
  const state = this.collections.get(collectionId)
1821
2010
  if (!state || !state.isLeader) {
1822
2011
  return {
@@ -1846,6 +2035,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
1846
2035
  }
1847
2036
  const pendingTx = {
1848
2037
  txId: safeRandomUUID(),
2038
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
1849
2039
  mutations: request.mutations.map((m) => ({
1850
2040
  type: m.type,
1851
2041
  key: m.key,
@@ -2056,6 +2246,17 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2056
2246
  )
2057
2247
  this.pruneAppliedEnvelopes()
2058
2248
 
2249
+ this.publishCommittedTx(collectionId, state, tx)
2250
+
2251
+ return response
2252
+ }
2253
+
2254
+ private publishCommittedTx(
2255
+ collectionId: string,
2256
+ state: CollectionState,
2257
+ tx: PersistedTx,
2258
+ forceReload = false,
2259
+ ): void {
2059
2260
  const committedBase = {
2060
2261
  type: `tx:committed` as const,
2061
2262
  term: tx.term,
@@ -2063,26 +2264,28 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2063
2264
  txId: tx.txId,
2064
2265
  latestRowVersion: tx.rowVersion,
2065
2266
  }
2066
- const committedPayload: TxCommitted = tx.truncate
2067
- ? {
2068
- ...committedBase,
2069
- requiresFullReload: true,
2070
- }
2071
- : {
2072
- ...committedBase,
2073
- requiresFullReload: false,
2074
- changedRows: tx.mutations
2075
- .filter((mutation) => mutation.type !== `delete`)
2076
- .map((mutation) => ({
2077
- key: mutation.key,
2078
- value: mutation.value,
2079
- })),
2080
- deletedKeys: tx.mutations
2081
- .filter((mutation) => mutation.type === `delete`)
2082
- .map((mutation) => mutation.key),
2083
- rowMetadataMutations: tx.rowMetadataMutations,
2084
- collectionMetadataMutations: tx.collectionMetadataMutations,
2085
- }
2267
+ const committedPayload: TxCommitted =
2268
+ tx.truncate || forceReload
2269
+ ? {
2270
+ ...committedBase,
2271
+ requiresFullReload: true,
2272
+ ...(forceReload ? { reconciliationReload: true as const } : {}),
2273
+ }
2274
+ : {
2275
+ ...committedBase,
2276
+ requiresFullReload: false,
2277
+ changedRows: tx.mutations
2278
+ .filter((mutation) => mutation.type !== `delete`)
2279
+ .map((mutation) => ({
2280
+ key: mutation.key,
2281
+ value: mutation.value,
2282
+ })),
2283
+ deletedKeys: tx.mutations
2284
+ .filter((mutation) => mutation.type === `delete`)
2285
+ .map((mutation) => mutation.key),
2286
+ rowMetadataMutations: tx.rowMetadataMutations,
2287
+ collectionMetadataMutations: tx.collectionMetadataMutations,
2288
+ }
2086
2289
  const committed: ProtocolEnvelope<TxCommitted> = {
2087
2290
  v: 1,
2088
2291
  dbName: this.dbName,
@@ -2095,10 +2298,133 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2095
2298
  for (const subscriber of state.subscribers) {
2096
2299
  subscriber(committed)
2097
2300
  }
2301
+ }
2302
+
2303
+ private async handleReconcileCommittedTx(
2304
+ collectionId: string,
2305
+ request: Extract<RPCRequest, { type: `rpc:reconcileCommittedTx:req` }>,
2306
+ scopedAdapter?: HydrationPersistenceAdapter,
2307
+ ): Promise<ReconcileCommittedTxResponse> {
2308
+ const state = this.collections.get(collectionId)
2309
+ const notLeader = (): ReconcileCommittedTxResponse => ({
2310
+ type: `rpc:reconcileCommittedTx:res`,
2311
+ rpcId: request.rpcId,
2312
+ ok: false,
2313
+ code: `NOT_LEADER`,
2314
+ error: `not the leader for ${collectionId}`,
2315
+ })
2316
+ if (!state?.isLeader) return notLeader()
2317
+ if (
2318
+ !isValidCommittedTxAnchor(request.anchor) ||
2319
+ !hasReconciliationTxPayload(request.tx)
2320
+ ) {
2321
+ return {
2322
+ type: `rpc:reconcileCommittedTx:res`,
2323
+ rpcId: request.rpcId,
2324
+ ok: false,
2325
+ code: `UNDETERMINED`,
2326
+ error: `the source transaction has no valid durable proof`,
2327
+ }
2328
+ }
2329
+
2330
+ const expectedTerm = state.latestTerm
2331
+ const result = await this.withScheduledWriterLock(
2332
+ collectionId,
2333
+ async (adapter) => {
2334
+ const candidate = this.nextPersistedTxIfLeader(
2335
+ collectionId,
2336
+ state,
2337
+ expectedTerm,
2338
+ request.tx,
2339
+ )
2340
+ if (candidate === null) return null
2341
+ if (!adapter.reconcileCommittedTx) return { kind: `unknown` as const }
2342
+ try {
2343
+ const reconciled = await adapter.reconcileCommittedTx(
2344
+ candidate.cacheGenerationClaimId
2345
+ ? managedStorageTarget(
2346
+ collectionId,
2347
+ candidate.cacheGenerationClaimId,
2348
+ )
2349
+ : eagerStorageTarget(collectionId),
2350
+ candidate,
2351
+ request.anchor,
2352
+ )
2353
+ if (reconciled.kind !== `unknown`) {
2354
+ state.latestRowVersion = Math.max(
2355
+ state.latestRowVersion,
2356
+ reconciled.latestRowVersion,
2357
+ )
2358
+ if (reconciled.kind === `applied-now`) {
2359
+ state.latestSeq = reconciled.committed.seq
2360
+ }
2361
+ }
2362
+ return reconciled
2363
+ } catch (error) {
2364
+ throw toPersistedCollectionDurabilityError(collectionId, error)
2365
+ }
2366
+ },
2367
+ scopedAdapter,
2368
+ )
2369
+ if (result === null) return notLeader()
2370
+ if (result.kind === `unknown`) {
2371
+ return {
2372
+ type: `rpc:reconcileCommittedTx:res`,
2373
+ rpcId: request.rpcId,
2374
+ ok: false,
2375
+ code: `UNDETERMINED`,
2376
+ error: `the durable outcome cannot be certified`,
2377
+ }
2378
+ }
2098
2379
 
2380
+ const current = {
2381
+ term: state.latestTerm,
2382
+ seq: state.latestSeq,
2383
+ rowVersion: state.latestRowVersion,
2384
+ }
2385
+ const response: ReconcileCommittedTxResponse = {
2386
+ type: `rpc:reconcileCommittedTx:res`,
2387
+ rpcId: request.rpcId,
2388
+ ok: true,
2389
+ alreadyApplied: result.kind === `already-applied`,
2390
+ committed: result.committed,
2391
+ }
2392
+ if (!this.isDisposed() && this.collections.get(collectionId) === state) {
2393
+ this.publishCommittedTx(
2394
+ collectionId,
2395
+ state,
2396
+ result.kind === `already-applied`
2397
+ ? { ...request.tx, ...current }
2398
+ : { ...request.tx, ...result.committed },
2399
+ result.kind === `already-applied`,
2400
+ )
2401
+ }
2099
2402
  return response
2100
2403
  }
2101
2404
 
2405
+ private nextPersistedTxIfLeader(
2406
+ collectionId: string,
2407
+ state: CollectionState,
2408
+ expectedTerm: number,
2409
+ pendingTx: Omit<PersistedTx, `term` | `seq` | `rowVersion`>,
2410
+ ): PersistedTx | null {
2411
+ if (
2412
+ this.isDisposed() ||
2413
+ this.collections.get(collectionId) !== state ||
2414
+ !state.isLeader ||
2415
+ state.leaderId !== this.nodeId ||
2416
+ state.latestTerm !== expectedTerm
2417
+ ) {
2418
+ return null
2419
+ }
2420
+ return {
2421
+ ...pendingTx,
2422
+ term: state.latestTerm,
2423
+ seq: state.latestSeq + 1,
2424
+ rowVersion: state.latestRowVersion + 1,
2425
+ }
2426
+ }
2427
+
2102
2428
  private async applyDurablyAtNextStreamPosition(
2103
2429
  collectionId: string,
2104
2430
  state: CollectionState,
@@ -2109,23 +2435,20 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2109
2435
  return this.withScheduledWriterLock(
2110
2436
  collectionId,
2111
2437
  async (adapter) => {
2112
- if (
2113
- this.isDisposed() ||
2114
- this.collections.get(collectionId) !== state ||
2115
- !state.isLeader ||
2116
- state.leaderId !== this.nodeId ||
2117
- state.latestTerm !== expectedTerm
2118
- ) {
2119
- return null
2120
- }
2121
- const tx: PersistedTx = {
2122
- ...pendingTx,
2123
- term: state.latestTerm,
2124
- seq: state.latestSeq + 1,
2125
- rowVersion: state.latestRowVersion + 1,
2126
- }
2438
+ const tx = this.nextPersistedTxIfLeader(
2439
+ collectionId,
2440
+ state,
2441
+ expectedTerm,
2442
+ pendingTx,
2443
+ )
2444
+ if (tx === null) return null
2127
2445
  try {
2128
- await adapter.applyCommittedTx(collectionId, tx)
2446
+ await adapter.applyCommittedTx(
2447
+ tx.cacheGenerationClaimId
2448
+ ? managedStorageTarget(collectionId, tx.cacheGenerationClaimId)
2449
+ : eagerStorageTarget(collectionId),
2450
+ tx,
2451
+ )
2129
2452
  } catch (error) {
2130
2453
  throw toPersistedCollectionDurabilityError(collectionId, error)
2131
2454
  }
@@ -2143,6 +2466,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2143
2466
  type: `rpc:pullSince:req`
2144
2467
  rpcId: string
2145
2468
  fromRowVersion: number
2469
+ cacheGenerationClaimId?: string
2146
2470
  },
2147
2471
  scopedAdapter?: HydrationPersistenceAdapter,
2148
2472
  ): Promise<PullSinceResponse> {
@@ -2161,7 +2485,15 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
2161
2485
  }
2162
2486
  }
2163
2487
 
2164
- const result = await adapter.pullSince(collectionId, request.fromRowVersion)
2488
+ const result = await adapter.pullSince(
2489
+ request.cacheGenerationClaimId
2490
+ ? managedStorageTarget(collectionId, request.cacheGenerationClaimId)
2491
+ : eagerStorageTarget(collectionId),
2492
+ request.fromRowVersion,
2493
+ {
2494
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
2495
+ },
2496
+ )
2165
2497
 
2166
2498
  if (result.requiresFullReload) {
2167
2499
  return {
@@ -2315,6 +2647,7 @@ function isRPCRequest(payload: unknown): payload is RPCRequest {
2315
2647
  case `rpc:ensurePersistedIndex:req`:
2316
2648
  case `rpc:applyLocalMutations:req`:
2317
2649
  case `rpc:applyCommittedTx:req`:
2650
+ case `rpc:reconcileCommittedTx:req`:
2318
2651
  case `rpc:pullSince:req`:
2319
2652
  return true
2320
2653
  default:
@@ -2322,6 +2655,12 @@ function isRPCRequest(payload: unknown): payload is RPCRequest {
2322
2655
  }
2323
2656
  }
2324
2657
 
2658
+ function hasReconciliationTxPayload(value: unknown): boolean {
2659
+ if (!value || typeof value !== `object`) return false
2660
+ const tx = value as Record<string, unknown>
2661
+ return typeof tx.txId === `string` && Array.isArray(tx.mutations)
2662
+ }
2663
+
2325
2664
  function sleep(ms: number): Promise<void> {
2326
2665
  return new Promise((resolve) => setTimeout(resolve, ms))
2327
2666
  }
@@ -2411,6 +2750,20 @@ function createRPCErrorResponse(
2411
2750
  code: `CONFLICT`,
2412
2751
  error,
2413
2752
  }
2753
+ case `rpc:reconcileCommittedTx:req`:
2754
+ return {
2755
+ type: `rpc:reconcileCommittedTx:res`,
2756
+ rpcId: request.rpcId,
2757
+ ok: false,
2758
+ code:
2759
+ cause instanceof PersistedCollectionDurabilityError
2760
+ ? `PERSISTENCE_ERROR`
2761
+ : `UNDETERMINED`,
2762
+ error,
2763
+ ...(cause instanceof PersistedCollectionDurabilityError
2764
+ ? toSafeDurabilityDetails(cause)
2765
+ : {}),
2766
+ }
2414
2767
  case `rpc:pullSince:req`:
2415
2768
  return {
2416
2769
  type: `rpc:pullSince:res`,