@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,9 @@
1
1
  import { safeRandomUUID } from "@tanstack/db";
2
- import { DuplicateRemoteSubsetOwnerError, RetryableRemoteSubsetAcquisitionError, IndeterminateCommitError, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
2
+ import { isValidCommittedTxAnchor } from "./committed-tx-anchor.js";
3
+ import { DuplicateRemoteSubsetOwnerError, InvalidPersistenceAdapterError, RetryableRemoteSubsetAcquisitionError, IndeterminateCommitError, InvalidPersistedCollectionConfigError, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
3
4
  import { toTransportedLoadSubsetOptions, toProcessLocalLoadSubsetOptions } from "./remote-subset-wire.js";
4
5
  import { unloadRemoteSubsetOwner } from "./remote-subset-owner.js";
6
+ import { managedStorageTarget, eagerStorageTarget } from "./persisted.js";
5
7
  const HEARTBEAT_INTERVAL_MS = 3e3;
6
8
  const LEADERSHIP_RETRY_DELAY_MS = 200;
7
9
  const RPC_TIMEOUT_MS = 1e4;
@@ -11,10 +13,16 @@ const REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS = 2;
11
13
  const RPC_DEDUPE_RETENTION_MS = 6e4;
12
14
  const WRITER_LOCK_BUSY_RETRY_MS = 50;
13
15
  const WRITER_LOCK_MAX_RETRIES = 20;
16
+ function requireDurableElection(adapter) {
17
+ if (typeof adapter.reserveLeadershipTerm !== `function`) {
18
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
19
+ }
20
+ }
14
21
  class BroadcastCollectionCoordinator {
15
22
  constructor(options) {
16
23
  this.nodeId = safeRandomUUID();
17
24
  this.collectionAdapters = /* @__PURE__ */ new Map();
25
+ this.collectionClaimIds = /* @__PURE__ */ new Map();
18
26
  this.remoteSubsetOwners = /* @__PURE__ */ new Map();
19
27
  this.remoteSubsetIds = /* @__PURE__ */ new Map();
20
28
  this.outboundRemoteSubsetAcquisitions = /* @__PURE__ */ new Map();
@@ -27,6 +35,7 @@ class BroadcastCollectionCoordinator {
27
35
  this.appliedEnvelopePruneTimer = null;
28
36
  this.rejectDisposed = null;
29
37
  this.disposed = false;
38
+ if (options.adapter) requireDurableElection(options.adapter);
30
39
  this.dbName = options.dbName;
31
40
  this.coordinatorName = options.coordinatorName;
32
41
  this.defaultAdapter = options.adapter ?? null;
@@ -65,11 +74,18 @@ class BroadcastCollectionCoordinator {
65
74
  * Called by a host persistence factory to wire the internally-created adapter.
66
75
  */
67
76
  setAdapter(adapter) {
77
+ requireDurableElection(adapter);
68
78
  this.defaultAdapter = adapter;
69
79
  }
70
80
  /** Register the persistence adapter that owns one collection. */
71
- setAdapterForCollection(collectionId, adapter) {
81
+ setAdapterForCollection(collectionId, adapter, cacheGenerationClaimId) {
82
+ requireDurableElection(adapter);
72
83
  this.collectionAdapters.set(collectionId, adapter);
84
+ if (cacheGenerationClaimId) {
85
+ this.collectionClaimIds.set(collectionId, cacheGenerationClaimId);
86
+ } else {
87
+ this.collectionClaimIds.delete(collectionId);
88
+ }
73
89
  }
74
90
  registerRemoteSubsetOwner(collectionId, owner) {
75
91
  if (this.remoteSubsetOwners.has(collectionId)) {
@@ -213,10 +229,29 @@ class BroadcastCollectionCoordinator {
213
229
  }
214
230
  try {
215
231
  await acquisition.release;
232
+ } catch (error) {
233
+ this.scheduleRemoteSubsetReleaseRetry(acquisition, options);
234
+ throw error;
216
235
  } finally {
217
236
  acquisition.release = null;
218
237
  }
219
238
  }
239
+ scheduleRemoteSubsetReleaseRetry(acquisition, options) {
240
+ const key = remoteSubsetAcquisitionKey(
241
+ acquisition.collectionId,
242
+ acquisition.acquisitionId
243
+ );
244
+ if (this.isDisposed() || this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition || acquisition.retryTimer !== null) {
245
+ return;
246
+ }
247
+ acquisition.retryTimer = setTimeout(() => {
248
+ acquisition.retryTimer = null;
249
+ void this.requestReleaseRemoteSubset(
250
+ acquisition.collectionId,
251
+ options
252
+ ).catch(() => void 0);
253
+ }, RPC_RETRY_DELAY_MS);
254
+ }
220
255
  async acquireRemoteSubset(acquisition) {
221
256
  if (acquisition.inFlight) return acquisition.inFlight;
222
257
  const route = { localOwner: false };
@@ -276,7 +311,9 @@ class BroadcastCollectionCoordinator {
276
311
  try {
277
312
  await work;
278
313
  acquired = true;
279
- this.cancelRemoteSubsetReplayRetry(acquisition);
314
+ if (!acquisition.releaseRequested) {
315
+ this.cancelRemoteSubsetReplayRetry(acquisition);
316
+ }
280
317
  } finally {
281
318
  if (acquisition.inFlight === work) acquisition.inFlight = null;
282
319
  const current = this.collections.get(acquisition.collectionId);
@@ -293,21 +330,34 @@ class BroadcastCollectionCoordinator {
293
330
  }
294
331
  }
295
332
  }
296
- async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false) {
333
+ async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false, cacheGenerationClaimId) {
297
334
  if (this.isLeader(collectionId)) {
298
335
  if (localEnsureCompleted) return;
299
- await (scopedAdapter ?? this.requireAdapter(collectionId)).ensureIndex(
300
- collectionId,
301
- signature,
302
- spec
303
- );
336
+ const adapter = scopedAdapter ?? this.requireAdapter(collectionId);
337
+ if (cacheGenerationClaimId) {
338
+ await adapter.ensureIndex(
339
+ managedStorageTarget(collectionId, cacheGenerationClaimId),
340
+ signature,
341
+ spec,
342
+ {
343
+ cacheGenerationClaimId
344
+ }
345
+ );
346
+ } else {
347
+ await adapter.ensureIndex(
348
+ eagerStorageTarget(collectionId),
349
+ signature,
350
+ spec
351
+ );
352
+ }
304
353
  return;
305
354
  }
306
355
  const response = await this.sendRPC(collectionId, {
307
356
  type: `rpc:ensurePersistedIndex:req`,
308
357
  rpcId: safeRandomUUID(),
309
358
  signature,
310
- spec
359
+ spec,
360
+ cacheGenerationClaimId
311
361
  });
312
362
  if (!response.ok) {
313
363
  throw new Error(
@@ -315,7 +365,7 @@ class BroadcastCollectionCoordinator {
315
365
  );
316
366
  }
317
367
  }
318
- async requestApplyLocalMutations(collectionId, mutations) {
368
+ async requestApplyLocalMutations(collectionId, mutations, cacheGenerationClaimId) {
319
369
  const initialRoute = this.waitForInitialRoute(collectionId);
320
370
  if (initialRoute) await initialRoute;
321
371
  if (this.isLeader(collectionId)) {
@@ -323,14 +373,16 @@ class BroadcastCollectionCoordinator {
323
373
  type: `rpc:applyLocalMutations:req`,
324
374
  rpcId: safeRandomUUID(),
325
375
  envelopeId: safeRandomUUID(),
326
- mutations
376
+ mutations,
377
+ cacheGenerationClaimId
327
378
  });
328
379
  }
329
380
  return this.sendRPC(collectionId, {
330
381
  type: `rpc:applyLocalMutations:req`,
331
382
  rpcId: safeRandomUUID(),
332
383
  envelopeId: safeRandomUUID(),
333
- mutations
384
+ mutations,
385
+ cacheGenerationClaimId
334
386
  });
335
387
  }
336
388
  async requestApplyCommittedTx(collectionId, tx, scopedAdapter) {
@@ -347,14 +399,39 @@ class BroadcastCollectionCoordinator {
347
399
  }
348
400
  return this.sendRPC(collectionId, request);
349
401
  }
350
- async pullSince(collectionId, fromRowVersion, scopedAdapter) {
402
+ async reconcileCommittedTx(collectionId, tx, anchor, scopedAdapter) {
403
+ const initialRoute = this.waitForInitialRoute(collectionId);
404
+ if (initialRoute) await initialRoute;
405
+ let response;
406
+ for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
407
+ if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt);
408
+ const request = {
409
+ type: `rpc:reconcileCommittedTx:req`,
410
+ rpcId: safeRandomUUID(),
411
+ tx,
412
+ anchor
413
+ };
414
+ response = this.isLeader(collectionId) ? await this.handleReconcileCommittedTx(
415
+ collectionId,
416
+ request,
417
+ scopedAdapter
418
+ ) : await this.sendRPC(
419
+ collectionId,
420
+ request
421
+ );
422
+ if (response.ok || response.code !== `NOT_LEADER`) return response;
423
+ }
424
+ return response;
425
+ }
426
+ async pullSince(collectionId, fromRowVersion, scopedAdapter, cacheGenerationClaimId) {
351
427
  if (this.isLeader(collectionId)) {
352
428
  return this.handlePullSince(
353
429
  collectionId,
354
430
  {
355
431
  type: `rpc:pullSince:req`,
356
432
  rpcId: safeRandomUUID(),
357
- fromRowVersion
433
+ fromRowVersion,
434
+ cacheGenerationClaimId
358
435
  },
359
436
  scopedAdapter
360
437
  );
@@ -362,7 +439,8 @@ class BroadcastCollectionCoordinator {
362
439
  return this.sendRPC(collectionId, {
363
440
  type: `rpc:pullSince:req`,
364
441
  rpcId: safeRandomUUID(),
365
- fromRowVersion
442
+ fromRowVersion,
443
+ cacheGenerationClaimId
366
444
  });
367
445
  }
368
446
  // -----------------------------------------------------------------------
@@ -395,6 +473,7 @@ class BroadcastCollectionCoordinator {
395
473
  this.channel.close();
396
474
  this.collections.clear();
397
475
  this.collectionAdapters.clear();
476
+ this.collectionClaimIds.clear();
398
477
  for (const collectionId of this.remoteSubsetOwners.keys()) {
399
478
  this.releaseInboundRemoteSubsetAcquisitions(collectionId);
400
479
  }
@@ -452,16 +531,24 @@ class BroadcastCollectionCoordinator {
452
531
  }
453
532
  try {
454
533
  const adapter = this.requireAdapter(collectionId);
455
- if (adapter.getStreamPosition) {
456
- const pos = await adapter.getStreamPosition(collectionId);
457
- state.latestTerm = pos.latestTerm;
458
- state.latestSeq = pos.latestSeq;
459
- state.latestRowVersion = pos.latestRowVersion;
460
- }
534
+ const reserveTerm = adapter.reserveLeadershipTerm;
535
+ if (!reserveTerm)
536
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
537
+ const pos = await this.withWriterLock(() => {
538
+ const claimId = this.collectionClaimIds.get(collectionId);
539
+ return reserveTerm.call(
540
+ adapter,
541
+ claimId ? managedStorageTarget(collectionId, claimId) : eagerStorageTarget(collectionId),
542
+ state.latestTerm,
543
+ { cacheGenerationClaimId: claimId }
544
+ );
545
+ });
546
+ state.latestTerm = pos.latestTerm;
547
+ state.latestSeq = pos.latestSeq;
548
+ state.latestRowVersion = pos.latestRowVersion;
461
549
  if (this.isDisposed() || !this.participatesInLeadership(state) || this.collections.get(collectionId) !== state || abortController.signal.aborted || state.lockAbortController !== abortController) {
462
550
  return;
463
551
  }
464
- state.latestTerm++;
465
552
  state.isLeader = true;
466
553
  state.leaderId = this.nodeId;
467
554
  this.notifyRouteWaiters(state);
@@ -602,6 +689,7 @@ class BroadcastCollectionCoordinator {
602
689
  this.releaseLeadership(collectionId, state);
603
690
  this.collections.delete(collectionId);
604
691
  this.collectionAdapters.delete(collectionId);
692
+ this.collectionClaimIds.delete(collectionId);
605
693
  const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`;
606
694
  for (const key of this.appliedEnvelopes.keys()) {
607
695
  if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key);
@@ -718,6 +806,7 @@ class BroadcastCollectionCoordinator {
718
806
  const heartbeat = payload;
719
807
  if (typeof heartbeat.leaderId === `string` && typeof heartbeat.term === `number` && typeof heartbeat.latestSeq === `number` && typeof heartbeat.latestRowVersion === `number`) {
720
808
  const state2 = this.getOrCreateCollectionState(envelope.collectionId);
809
+ if (state2.isLeader) return;
721
810
  if (heartbeat.term < state2.latestTerm) return;
722
811
  const changedLeader = state2.leaderId !== heartbeat.leaderId;
723
812
  state2.leaderId = heartbeat.leaderId;
@@ -898,6 +987,8 @@ class BroadcastCollectionCoordinator {
898
987
  return this.handleApplyLocalMutations(collectionId, request);
899
988
  case `rpc:applyCommittedTx:req`:
900
989
  return this.handleApplyCommittedTx(collectionId, request);
990
+ case `rpc:reconcileCommittedTx:req`:
991
+ return this.handleReconcileCommittedTx(collectionId, request);
901
992
  case `rpc:pullSince:req`:
902
993
  return this.handlePullSince(collectionId, request);
903
994
  }
@@ -1012,10 +1103,7 @@ class BroadcastCollectionCoordinator {
1012
1103
  acquisition.released = true;
1013
1104
  acquisition.release = (async () => {
1014
1105
  try {
1015
- await acquisition.load;
1016
- } catch {
1017
- }
1018
- try {
1106
+ await acquisition.admitted;
1019
1107
  if (acquisition.transferred) {
1020
1108
  await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options);
1021
1109
  }
@@ -1131,6 +1219,7 @@ class BroadcastCollectionCoordinator {
1131
1219
  owner: options.owner,
1132
1220
  options: options.options,
1133
1221
  load: Promise.resolve(),
1222
+ admitted: Promise.resolve(),
1134
1223
  transferred: false,
1135
1224
  released: false,
1136
1225
  terminalRelease: false,
@@ -1138,6 +1227,10 @@ class BroadcastCollectionCoordinator {
1138
1227
  };
1139
1228
  this.releasedRemoteSubsetAcquisitionTimes.delete(key);
1140
1229
  this.inboundRemoteSubsetAcquisitions.set(key, acquisition);
1230
+ let resolveAdmission;
1231
+ acquisition.admitted = new Promise((resolve) => {
1232
+ resolveAdmission = resolve;
1233
+ });
1141
1234
  let resolveLoad;
1142
1235
  let rejectLoad;
1143
1236
  acquisition.load = new Promise((resolve, reject) => {
@@ -1150,6 +1243,8 @@ class BroadcastCollectionCoordinator {
1150
1243
  void Promise.resolve(load).then(resolveLoad, rejectLoad);
1151
1244
  } catch (error) {
1152
1245
  rejectLoad(error);
1246
+ } finally {
1247
+ resolveAdmission();
1153
1248
  }
1154
1249
  try {
1155
1250
  await acquisition.load;
@@ -1174,10 +1269,23 @@ class BroadcastCollectionCoordinator {
1174
1269
  }
1175
1270
  }
1176
1271
  async handleEnsurePersistedIndex(collectionId, request) {
1177
- await this.withScheduledWriterLock(
1178
- collectionId,
1179
- (adapter) => adapter.ensureIndex(collectionId, request.signature, request.spec)
1180
- );
1272
+ await this.withScheduledWriterLock(collectionId, (adapter) => {
1273
+ if (request.cacheGenerationClaimId) {
1274
+ return adapter.ensureIndex(
1275
+ managedStorageTarget(collectionId, request.cacheGenerationClaimId),
1276
+ request.signature,
1277
+ request.spec,
1278
+ {
1279
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1280
+ }
1281
+ );
1282
+ }
1283
+ return adapter.ensureIndex(
1284
+ eagerStorageTarget(collectionId),
1285
+ request.signature,
1286
+ request.spec
1287
+ );
1288
+ });
1181
1289
  return {
1182
1290
  type: `rpc:ensurePersistedIndex:res`,
1183
1291
  rpcId: request.rpcId,
@@ -1206,6 +1314,11 @@ class BroadcastCollectionCoordinator {
1206
1314
  });
1207
1315
  }
1208
1316
  async applyLocalMutationsOnce(collectionId, request) {
1317
+ if (this.collectionClaimIds.has(collectionId) && !request.cacheGenerationClaimId) {
1318
+ throw new InvalidPersistedCollectionConfigError(
1319
+ `A managed local mutation requires a persisted cache claim`
1320
+ );
1321
+ }
1209
1322
  const state = this.collections.get(collectionId);
1210
1323
  if (!state || !state.isLeader) {
1211
1324
  return {
@@ -1231,6 +1344,7 @@ class BroadcastCollectionCoordinator {
1231
1344
  }
1232
1345
  const pendingTx = {
1233
1346
  txId: safeRandomUUID(),
1347
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
1234
1348
  mutations: request.mutations.map((m) => ({
1235
1349
  type: m.type,
1236
1350
  key: m.key,
@@ -1391,6 +1505,10 @@ class BroadcastCollectionCoordinator {
1391
1505
  }
1392
1506
  );
1393
1507
  this.pruneAppliedEnvelopes();
1508
+ this.publishCommittedTx(collectionId, state, tx);
1509
+ return response;
1510
+ }
1511
+ publishCommittedTx(collectionId, state, tx, forceReload = false) {
1394
1512
  const committedBase = {
1395
1513
  type: `tx:committed`,
1396
1514
  term: tx.term,
@@ -1398,9 +1516,10 @@ class BroadcastCollectionCoordinator {
1398
1516
  txId: tx.txId,
1399
1517
  latestRowVersion: tx.rowVersion
1400
1518
  };
1401
- const committedPayload = tx.truncate ? {
1519
+ const committedPayload = tx.truncate || forceReload ? {
1402
1520
  ...committedBase,
1403
- requiresFullReload: true
1521
+ requiresFullReload: true,
1522
+ ...forceReload ? { reconciliationReload: true } : {}
1404
1523
  } : {
1405
1524
  ...committedBase,
1406
1525
  requiresFullReload: false,
@@ -1424,24 +1543,123 @@ class BroadcastCollectionCoordinator {
1424
1543
  for (const subscriber of state.subscribers) {
1425
1544
  subscriber(committed);
1426
1545
  }
1546
+ }
1547
+ async handleReconcileCommittedTx(collectionId, request, scopedAdapter) {
1548
+ const state = this.collections.get(collectionId);
1549
+ const notLeader = () => ({
1550
+ type: `rpc:reconcileCommittedTx:res`,
1551
+ rpcId: request.rpcId,
1552
+ ok: false,
1553
+ code: `NOT_LEADER`,
1554
+ error: `not the leader for ${collectionId}`
1555
+ });
1556
+ if (!state?.isLeader) return notLeader();
1557
+ if (!isValidCommittedTxAnchor(request.anchor) || !hasReconciliationTxPayload(request.tx)) {
1558
+ return {
1559
+ type: `rpc:reconcileCommittedTx:res`,
1560
+ rpcId: request.rpcId,
1561
+ ok: false,
1562
+ code: `UNDETERMINED`,
1563
+ error: `the source transaction has no valid durable proof`
1564
+ };
1565
+ }
1566
+ const expectedTerm = state.latestTerm;
1567
+ const result = await this.withScheduledWriterLock(
1568
+ collectionId,
1569
+ async (adapter) => {
1570
+ const candidate = this.nextPersistedTxIfLeader(
1571
+ collectionId,
1572
+ state,
1573
+ expectedTerm,
1574
+ request.tx
1575
+ );
1576
+ if (candidate === null) return null;
1577
+ if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
1578
+ try {
1579
+ const reconciled = await adapter.reconcileCommittedTx(
1580
+ candidate.cacheGenerationClaimId ? managedStorageTarget(
1581
+ collectionId,
1582
+ candidate.cacheGenerationClaimId
1583
+ ) : eagerStorageTarget(collectionId),
1584
+ candidate,
1585
+ request.anchor
1586
+ );
1587
+ if (reconciled.kind !== `unknown`) {
1588
+ state.latestRowVersion = Math.max(
1589
+ state.latestRowVersion,
1590
+ reconciled.latestRowVersion
1591
+ );
1592
+ if (reconciled.kind === `applied-now`) {
1593
+ state.latestSeq = reconciled.committed.seq;
1594
+ }
1595
+ }
1596
+ return reconciled;
1597
+ } catch (error) {
1598
+ throw toPersistedCollectionDurabilityError(collectionId, error);
1599
+ }
1600
+ },
1601
+ scopedAdapter
1602
+ );
1603
+ if (result === null) return notLeader();
1604
+ if (result.kind === `unknown`) {
1605
+ return {
1606
+ type: `rpc:reconcileCommittedTx:res`,
1607
+ rpcId: request.rpcId,
1608
+ ok: false,
1609
+ code: `UNDETERMINED`,
1610
+ error: `the durable outcome cannot be certified`
1611
+ };
1612
+ }
1613
+ const current = {
1614
+ term: state.latestTerm,
1615
+ seq: state.latestSeq,
1616
+ rowVersion: state.latestRowVersion
1617
+ };
1618
+ const response = {
1619
+ type: `rpc:reconcileCommittedTx:res`,
1620
+ rpcId: request.rpcId,
1621
+ ok: true,
1622
+ alreadyApplied: result.kind === `already-applied`,
1623
+ committed: result.committed
1624
+ };
1625
+ if (!this.isDisposed() && this.collections.get(collectionId) === state) {
1626
+ this.publishCommittedTx(
1627
+ collectionId,
1628
+ state,
1629
+ result.kind === `already-applied` ? { ...request.tx, ...current } : { ...request.tx, ...result.committed },
1630
+ result.kind === `already-applied`
1631
+ );
1632
+ }
1427
1633
  return response;
1428
1634
  }
1635
+ nextPersistedTxIfLeader(collectionId, state, expectedTerm, pendingTx) {
1636
+ if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
1637
+ return null;
1638
+ }
1639
+ return {
1640
+ ...pendingTx,
1641
+ term: state.latestTerm,
1642
+ seq: state.latestSeq + 1,
1643
+ rowVersion: state.latestRowVersion + 1
1644
+ };
1645
+ }
1429
1646
  async applyDurablyAtNextStreamPosition(collectionId, state, pendingTx, scopedAdapter) {
1430
1647
  const expectedTerm = state.latestTerm;
1431
1648
  return this.withScheduledWriterLock(
1432
1649
  collectionId,
1433
1650
  async (adapter) => {
1434
- if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
1435
- return null;
1436
- }
1437
- const tx = {
1438
- ...pendingTx,
1439
- term: state.latestTerm,
1440
- seq: state.latestSeq + 1,
1441
- rowVersion: state.latestRowVersion + 1
1442
- };
1651
+ const tx = this.nextPersistedTxIfLeader(
1652
+ collectionId,
1653
+ state,
1654
+ expectedTerm,
1655
+ pendingTx
1656
+ );
1657
+ if (tx === null) return null;
1443
1658
  try {
1444
- await adapter.applyCommittedTx(collectionId, tx);
1659
+ await adapter.applyCommittedTx(
1660
+ tx.cacheGenerationClaimId ? managedStorageTarget(collectionId, tx.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
1661
+ tx
1662
+ );
1445
1663
  } catch (error) {
1446
1664
  throw toPersistedCollectionDurabilityError(collectionId, error);
1447
1665
  }
@@ -1466,7 +1684,13 @@ class BroadcastCollectionCoordinator {
1466
1684
  requiresFullReload: true
1467
1685
  };
1468
1686
  }
1469
- const result = await adapter.pullSince(collectionId, request.fromRowVersion);
1687
+ const result = await adapter.pullSince(
1688
+ request.cacheGenerationClaimId ? managedStorageTarget(collectionId, request.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
1689
+ request.fromRowVersion,
1690
+ {
1691
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1692
+ }
1693
+ );
1470
1694
  if (result.requiresFullReload) {
1471
1695
  return {
1472
1696
  type: `rpc:pullSince:res`,
@@ -1582,12 +1806,18 @@ function isRPCRequest(payload) {
1582
1806
  case `rpc:ensurePersistedIndex:req`:
1583
1807
  case `rpc:applyLocalMutations:req`:
1584
1808
  case `rpc:applyCommittedTx:req`:
1809
+ case `rpc:reconcileCommittedTx:req`:
1585
1810
  case `rpc:pullSince:req`:
1586
1811
  return true;
1587
1812
  default:
1588
1813
  return false;
1589
1814
  }
1590
1815
  }
1816
+ function hasReconciliationTxPayload(value) {
1817
+ if (!value || typeof value !== `object`) return false;
1818
+ const tx = value;
1819
+ return typeof tx.txId === `string` && Array.isArray(tx.mutations);
1820
+ }
1591
1821
  function sleep(ms) {
1592
1822
  return new Promise((resolve) => setTimeout(resolve, ms));
1593
1823
  }
@@ -1661,6 +1891,15 @@ function createRPCErrorResponse(request, cause) {
1661
1891
  code: `CONFLICT`,
1662
1892
  error
1663
1893
  };
1894
+ case `rpc:reconcileCommittedTx:req`:
1895
+ return {
1896
+ type: `rpc:reconcileCommittedTx:res`,
1897
+ rpcId: request.rpcId,
1898
+ ok: false,
1899
+ code: cause instanceof PersistedCollectionDurabilityError ? `PERSISTENCE_ERROR` : `UNDETERMINED`,
1900
+ error,
1901
+ ...cause instanceof PersistedCollectionDurabilityError ? toSafeDurabilityDetails(cause) : {}
1902
+ };
1664
1903
  case `rpc:pullSince:req`:
1665
1904
  return {
1666
1905
  type: `rpc:pullSince:res`,