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

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/README.md CHANGED
@@ -172,10 +172,23 @@ semantics.
172
172
  After a mutating RPC transport failure, replay is allowed only while the
173
173
  requester can prove the same non-null leader id and term still own the route.
174
174
  If either value was unknown for the first attempt, or either value changes,
175
- the request rejects with `IndeterminateCommitError` and requires application
176
- reconciliation. Coordinators never retry an indeterminate mutation against an
175
+ the direct request rejects with `IndeterminateCommitError` and requires
176
+ application reconciliation. Coordinators never retry that mutation against an
177
177
  unknown or replacement leader.
178
178
 
179
+ A source sync transaction has a separate exact-ID reconciliation path. The
180
+ persisted wrapper holds its applied receipt while an elected coordinator and
181
+ SQLite adapter check the transaction under the writer lock. An existing ID
182
+ acknowledges the original durable position. An absent ID permits one application
183
+ only if the durable row version and reset epoch equal the wrapper's pre-send
184
+ snapshot; another durable write may carry newer row values or an opaque source
185
+ cursor and cannot be overwritten safely. On a certified result the wrapper
186
+ fulfills the receipt in the same sync run and the coordinator invalidates peers,
187
+ including a peer that missed the original notification. An uncertified result
188
+ retains `IndeterminateCommitError` and terminal source failure. Custom adapters
189
+ and coordinators without this optional operation keep that terminal behavior.
190
+ One generated `txId` identifies one immutable source transaction payload.
191
+
179
192
  `SingleProcessCoordinator` also implements the complete route. It is always
180
193
  leader and has no cross-process transport, but it applies the transaction to
181
194
  the resolved adapter registered for the specific collection. The Browser and
@@ -294,6 +307,13 @@ row-bearing snapshot.
294
307
  - `unknown`: the adapter has no authoritative pre-migration key set and does
295
308
  not claim completeness.
296
309
 
310
+ A no-write election can reserve a greater durable term and reset its sequence
311
+ to zero without changing persisted rows or the source resume cursor. The
312
+ persisted wrapper keeps an already certified resume baseline across that
313
+ transition only when the atomic snapshot has the same row version and reset
314
+ epoch. A committed write or schema reset requires fresh certification and
315
+ cannot inherit the earlier baseline merely because the key set still matches.
316
+
297
317
  The method is required because the versioned `metadata.persistence` capability
298
318
  always carries hydration, durable row scanning, certification, evidence, and
299
319
  generation ownership as one complete bundle. Sync wrappers must forward the
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const db = require("@tanstack/db");
4
+ const committedTxAnchor = require("./committed-tx-anchor.cjs");
4
5
  const errors = require("./errors.cjs");
5
6
  const remoteSubsetWire = require("./remote-subset-wire.cjs");
6
7
  const remoteSubsetOwner = require("./remote-subset-owner.cjs");
@@ -13,6 +14,11 @@ const REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS = 2;
13
14
  const RPC_DEDUPE_RETENTION_MS = 6e4;
14
15
  const WRITER_LOCK_BUSY_RETRY_MS = 50;
15
16
  const WRITER_LOCK_MAX_RETRIES = 20;
17
+ function requireDurableElection(adapter) {
18
+ if (typeof adapter.reserveLeadershipTerm !== `function`) {
19
+ throw new errors.InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
20
+ }
21
+ }
16
22
  class BroadcastCollectionCoordinator {
17
23
  constructor(options) {
18
24
  this.nodeId = db.safeRandomUUID();
@@ -29,6 +35,7 @@ class BroadcastCollectionCoordinator {
29
35
  this.appliedEnvelopePruneTimer = null;
30
36
  this.rejectDisposed = null;
31
37
  this.disposed = false;
38
+ if (options.adapter) requireDurableElection(options.adapter);
32
39
  this.dbName = options.dbName;
33
40
  this.coordinatorName = options.coordinatorName;
34
41
  this.defaultAdapter = options.adapter ?? null;
@@ -67,10 +74,12 @@ class BroadcastCollectionCoordinator {
67
74
  * Called by a host persistence factory to wire the internally-created adapter.
68
75
  */
69
76
  setAdapter(adapter) {
77
+ requireDurableElection(adapter);
70
78
  this.defaultAdapter = adapter;
71
79
  }
72
80
  /** Register the persistence adapter that owns one collection. */
73
81
  setAdapterForCollection(collectionId, adapter) {
82
+ requireDurableElection(adapter);
74
83
  this.collectionAdapters.set(collectionId, adapter);
75
84
  }
76
85
  registerRemoteSubsetOwner(collectionId, owner) {
@@ -349,6 +358,30 @@ class BroadcastCollectionCoordinator {
349
358
  }
350
359
  return this.sendRPC(collectionId, request);
351
360
  }
361
+ async reconcileCommittedTx(collectionId, tx, anchor, scopedAdapter) {
362
+ const initialRoute = this.waitForInitialRoute(collectionId);
363
+ if (initialRoute) await initialRoute;
364
+ let response;
365
+ for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
366
+ if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt);
367
+ const request = {
368
+ type: `rpc:reconcileCommittedTx:req`,
369
+ rpcId: db.safeRandomUUID(),
370
+ tx,
371
+ anchor
372
+ };
373
+ response = this.isLeader(collectionId) ? await this.handleReconcileCommittedTx(
374
+ collectionId,
375
+ request,
376
+ scopedAdapter
377
+ ) : await this.sendRPC(
378
+ collectionId,
379
+ request
380
+ );
381
+ if (response.ok || response.code !== `NOT_LEADER`) return response;
382
+ }
383
+ return response;
384
+ }
352
385
  async pullSince(collectionId, fromRowVersion, scopedAdapter) {
353
386
  if (this.isLeader(collectionId)) {
354
387
  return this.handlePullSince(
@@ -454,16 +487,18 @@ class BroadcastCollectionCoordinator {
454
487
  }
455
488
  try {
456
489
  const adapter = this.requireAdapter(collectionId);
457
- if (adapter.getStreamPosition) {
458
- const pos = await adapter.getStreamPosition(collectionId);
459
- state.latestTerm = pos.latestTerm;
460
- state.latestSeq = pos.latestSeq;
461
- state.latestRowVersion = pos.latestRowVersion;
462
- }
490
+ const reserveTerm = adapter.reserveLeadershipTerm;
491
+ if (!reserveTerm)
492
+ throw new errors.InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
493
+ const pos = await this.withWriterLock(
494
+ () => reserveTerm.call(adapter, collectionId, state.latestTerm)
495
+ );
496
+ state.latestTerm = pos.latestTerm;
497
+ state.latestSeq = pos.latestSeq;
498
+ state.latestRowVersion = pos.latestRowVersion;
463
499
  if (this.isDisposed() || !this.participatesInLeadership(state) || this.collections.get(collectionId) !== state || abortController.signal.aborted || state.lockAbortController !== abortController) {
464
500
  return;
465
501
  }
466
- state.latestTerm++;
467
502
  state.isLeader = true;
468
503
  state.leaderId = this.nodeId;
469
504
  this.notifyRouteWaiters(state);
@@ -720,6 +755,7 @@ class BroadcastCollectionCoordinator {
720
755
  const heartbeat = payload;
721
756
  if (typeof heartbeat.leaderId === `string` && typeof heartbeat.term === `number` && typeof heartbeat.latestSeq === `number` && typeof heartbeat.latestRowVersion === `number`) {
722
757
  const state2 = this.getOrCreateCollectionState(envelope.collectionId);
758
+ if (state2.isLeader) return;
723
759
  if (heartbeat.term < state2.latestTerm) return;
724
760
  const changedLeader = state2.leaderId !== heartbeat.leaderId;
725
761
  state2.leaderId = heartbeat.leaderId;
@@ -900,6 +936,8 @@ class BroadcastCollectionCoordinator {
900
936
  return this.handleApplyLocalMutations(collectionId, request);
901
937
  case `rpc:applyCommittedTx:req`:
902
938
  return this.handleApplyCommittedTx(collectionId, request);
939
+ case `rpc:reconcileCommittedTx:req`:
940
+ return this.handleReconcileCommittedTx(collectionId, request);
903
941
  case `rpc:pullSince:req`:
904
942
  return this.handlePullSince(collectionId, request);
905
943
  }
@@ -1393,6 +1431,10 @@ class BroadcastCollectionCoordinator {
1393
1431
  }
1394
1432
  );
1395
1433
  this.pruneAppliedEnvelopes();
1434
+ this.publishCommittedTx(collectionId, state, tx);
1435
+ return response;
1436
+ }
1437
+ publishCommittedTx(collectionId, state, tx, forceReload = false) {
1396
1438
  const committedBase = {
1397
1439
  type: `tx:committed`,
1398
1440
  term: tx.term,
@@ -1400,9 +1442,10 @@ class BroadcastCollectionCoordinator {
1400
1442
  txId: tx.txId,
1401
1443
  latestRowVersion: tx.rowVersion
1402
1444
  };
1403
- const committedPayload = tx.truncate ? {
1445
+ const committedPayload = tx.truncate || forceReload ? {
1404
1446
  ...committedBase,
1405
- requiresFullReload: true
1447
+ requiresFullReload: true,
1448
+ ...forceReload ? { reconciliationReload: true } : {}
1406
1449
  } : {
1407
1450
  ...committedBase,
1408
1451
  requiresFullReload: false,
@@ -1426,22 +1469,115 @@ class BroadcastCollectionCoordinator {
1426
1469
  for (const subscriber of state.subscribers) {
1427
1470
  subscriber(committed);
1428
1471
  }
1472
+ }
1473
+ async handleReconcileCommittedTx(collectionId, request, scopedAdapter) {
1474
+ const state = this.collections.get(collectionId);
1475
+ const notLeader = () => ({
1476
+ type: `rpc:reconcileCommittedTx:res`,
1477
+ rpcId: request.rpcId,
1478
+ ok: false,
1479
+ code: `NOT_LEADER`,
1480
+ error: `not the leader for ${collectionId}`
1481
+ });
1482
+ if (!state?.isLeader) return notLeader();
1483
+ if (!committedTxAnchor.isValidCommittedTxAnchor(request.anchor) || !hasReconciliationTxPayload(request.tx)) {
1484
+ return {
1485
+ type: `rpc:reconcileCommittedTx:res`,
1486
+ rpcId: request.rpcId,
1487
+ ok: false,
1488
+ code: `UNDETERMINED`,
1489
+ error: `the source transaction has no valid durable proof`
1490
+ };
1491
+ }
1492
+ const expectedTerm = state.latestTerm;
1493
+ const result = await this.withScheduledWriterLock(
1494
+ collectionId,
1495
+ async (adapter) => {
1496
+ const candidate = this.nextPersistedTxIfLeader(
1497
+ collectionId,
1498
+ state,
1499
+ expectedTerm,
1500
+ request.tx
1501
+ );
1502
+ if (candidate === null) return null;
1503
+ if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
1504
+ try {
1505
+ const reconciled = await adapter.reconcileCommittedTx(
1506
+ collectionId,
1507
+ candidate,
1508
+ request.anchor
1509
+ );
1510
+ if (reconciled.kind !== `unknown`) {
1511
+ state.latestRowVersion = Math.max(
1512
+ state.latestRowVersion,
1513
+ reconciled.latestRowVersion
1514
+ );
1515
+ if (reconciled.kind === `applied-now`) {
1516
+ state.latestSeq = reconciled.committed.seq;
1517
+ }
1518
+ }
1519
+ return reconciled;
1520
+ } catch (error) {
1521
+ throw errors.toPersistedCollectionDurabilityError(collectionId, error);
1522
+ }
1523
+ },
1524
+ scopedAdapter
1525
+ );
1526
+ if (result === null) return notLeader();
1527
+ if (result.kind === `unknown`) {
1528
+ return {
1529
+ type: `rpc:reconcileCommittedTx:res`,
1530
+ rpcId: request.rpcId,
1531
+ ok: false,
1532
+ code: `UNDETERMINED`,
1533
+ error: `the durable outcome cannot be certified`
1534
+ };
1535
+ }
1536
+ const current = {
1537
+ term: state.latestTerm,
1538
+ seq: state.latestSeq,
1539
+ rowVersion: state.latestRowVersion
1540
+ };
1541
+ const response = {
1542
+ type: `rpc:reconcileCommittedTx:res`,
1543
+ rpcId: request.rpcId,
1544
+ ok: true,
1545
+ alreadyApplied: result.kind === `already-applied`,
1546
+ committed: result.committed
1547
+ };
1548
+ if (!this.isDisposed() && this.collections.get(collectionId) === state) {
1549
+ this.publishCommittedTx(
1550
+ collectionId,
1551
+ state,
1552
+ result.kind === `already-applied` ? { ...request.tx, ...current } : { ...request.tx, ...result.committed },
1553
+ result.kind === `already-applied`
1554
+ );
1555
+ }
1429
1556
  return response;
1430
1557
  }
1558
+ nextPersistedTxIfLeader(collectionId, state, expectedTerm, pendingTx) {
1559
+ if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
1560
+ return null;
1561
+ }
1562
+ return {
1563
+ ...pendingTx,
1564
+ term: state.latestTerm,
1565
+ seq: state.latestSeq + 1,
1566
+ rowVersion: state.latestRowVersion + 1
1567
+ };
1568
+ }
1431
1569
  async applyDurablyAtNextStreamPosition(collectionId, state, pendingTx, scopedAdapter) {
1432
1570
  const expectedTerm = state.latestTerm;
1433
1571
  return this.withScheduledWriterLock(
1434
1572
  collectionId,
1435
1573
  async (adapter) => {
1436
- if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
1437
- return null;
1438
- }
1439
- const tx = {
1440
- ...pendingTx,
1441
- term: state.latestTerm,
1442
- seq: state.latestSeq + 1,
1443
- rowVersion: state.latestRowVersion + 1
1444
- };
1574
+ const tx = this.nextPersistedTxIfLeader(
1575
+ collectionId,
1576
+ state,
1577
+ expectedTerm,
1578
+ pendingTx
1579
+ );
1580
+ if (tx === null) return null;
1445
1581
  try {
1446
1582
  await adapter.applyCommittedTx(collectionId, tx);
1447
1583
  } catch (error) {
@@ -1584,12 +1720,18 @@ function isRPCRequest(payload) {
1584
1720
  case `rpc:ensurePersistedIndex:req`:
1585
1721
  case `rpc:applyLocalMutations:req`:
1586
1722
  case `rpc:applyCommittedTx:req`:
1723
+ case `rpc:reconcileCommittedTx:req`:
1587
1724
  case `rpc:pullSince:req`:
1588
1725
  return true;
1589
1726
  default:
1590
1727
  return false;
1591
1728
  }
1592
1729
  }
1730
+ function hasReconciliationTxPayload(value) {
1731
+ if (!value || typeof value !== `object`) return false;
1732
+ const tx = value;
1733
+ return typeof tx.txId === `string` && Array.isArray(tx.mutations);
1734
+ }
1593
1735
  function sleep(ms) {
1594
1736
  return new Promise((resolve) => setTimeout(resolve, ms));
1595
1737
  }
@@ -1663,6 +1805,15 @@ function createRPCErrorResponse(request, cause) {
1663
1805
  code: `CONFLICT`,
1664
1806
  error
1665
1807
  };
1808
+ case `rpc:reconcileCommittedTx:req`:
1809
+ return {
1810
+ type: `rpc:reconcileCommittedTx:res`,
1811
+ rpcId: request.rpcId,
1812
+ ok: false,
1813
+ code: cause instanceof errors.PersistedCollectionDurabilityError ? `PERSISTENCE_ERROR` : `UNDETERMINED`,
1814
+ error,
1815
+ ...cause instanceof errors.PersistedCollectionDurabilityError ? toSafeDurabilityDetails(cause) : {}
1816
+ };
1666
1817
  case `rpc:pullSince:req`:
1667
1818
  return {
1668
1819
  type: `rpc:pullSince:res`,