@tanstack/db-sqlite-persistence-core 0.4.5 → 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.
@@ -1,5 +1,5 @@
1
1
  import { LoadSubsetOptions } from '@tanstack/db';
2
- import { HydrationPersistenceAdapter, PersistedIndexSpec, PersistedKeySetEvidence, PersistedRowScanOptions, PersistedScannedRow, PersistedTx, PersistenceAdapter, ReplayableTxDelta, SQLiteDriver } from './persisted.cjs';
2
+ import { CommittedTxAnchor, HydrationPersistenceAdapter, PersistedIndexSpec, PersistedKeySetEvidence, PersistedRowScanOptions, PersistedScannedRow, PersistedTx, PersistenceAdapter, ReconciledCommittedTx, ReplayableTxDelta, SQLiteDriver } from './persisted.cjs';
3
3
  type SQLiteCoreAdapterSchemaMismatchPolicy = `sync-present-reset` | `sync-absent-error` | `reset`;
4
4
  export type SQLiteCoreAdapterOptions = {
5
5
  driver: SQLiteDriver;
@@ -83,7 +83,10 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
83
83
  }>;
84
84
  private loadResumeSnapshotUnscheduled;
85
85
  applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void>;
86
+ reconcileCommittedTx(collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor): Promise<ReconciledCommittedTx>;
86
87
  private applyCommittedTxUnscheduled;
88
+ private reconcileCommittedTxUnscheduled;
89
+ private applyCommittedTxInternal;
87
90
  loadCollectionMetadata(collectionId: string): Promise<Array<{
88
91
  key: string;
89
92
  value: unknown;
@@ -95,6 +98,12 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
95
98
  private ensureIndexUnscheduled;
96
99
  markIndexRemoved(collectionId: string, signature: string): Promise<void>;
97
100
  private markIndexRemovedUnscheduled;
101
+ reserveLeadershipTerm(collectionId: string, observedTerm: number): Promise<{
102
+ latestTerm: number;
103
+ latestSeq: number;
104
+ latestRowVersion: number;
105
+ }>;
106
+ private reserveLeadershipTermUnscheduled;
98
107
  getStreamPosition(collectionId: string): Promise<{
99
108
  latestTerm: number;
100
109
  latestSeq: number;
@@ -1,7 +1,7 @@
1
1
  import { LoadSubsetOptions } from '@tanstack/db';
2
2
  import { RemoteSubsetOwner } from './remote-subset-owner.js';
3
- import { ApplyCommittedTxResponse, ApplyLocalMutationsResponse, HydrationPersistenceAdapter, PersistedCollectionCoordinator, PersistedIndexSpec, PersistedMutationEnvelope, PersistedTx, PersistenceAdapter, ProtocolEnvelope, PullSinceResponse } from './persisted.js';
4
- type CoordinatorAdapter = Pick<PersistenceAdapter, `loadSubset` | `applyCommittedTx` | `ensureIndex` | `runInRegularScope`> & {
3
+ import { ApplyCommittedTxResponse, ApplyLocalMutationsResponse, CommittedTxAnchor, HydrationPersistenceAdapter, PersistedCollectionCoordinator, PersistedIndexSpec, PersistedMutationEnvelope, PersistedTx, PersistenceAdapter, ProtocolEnvelope, PullSinceResponse, ReconcileCommittedTxResponse } from './persisted.js';
4
+ type CoordinatorAdapter = Pick<PersistenceAdapter, `loadSubset` | `applyCommittedTx` | `reconcileCommittedTx` | `ensureIndex` | `runInRegularScope`> & {
5
5
  pullSince?: (collectionId: string, fromRowVersion: number) => Promise<{
6
6
  latestRowVersion: number;
7
7
  requiresFullReload: true;
@@ -16,6 +16,11 @@ type CoordinatorAdapter = Pick<PersistenceAdapter, `loadSubset` | `applyCommitte
16
16
  latestSeq: number;
17
17
  latestRowVersion: number;
18
18
  }>;
19
+ reserveLeadershipTerm?: (collectionId: string, observedTerm: number) => Promise<{
20
+ latestTerm: number;
21
+ latestSeq: number;
22
+ latestRowVersion: number;
23
+ }>;
19
24
  };
20
25
  /** @internal Shared by the Browser and Electron persistence hosts. */
21
26
  export type BroadcastCollectionCoordinatorOptions = {
@@ -69,6 +74,7 @@ export declare class BroadcastCollectionCoordinator implements PersistedCollecti
69
74
  requestEnsurePersistedIndex(collectionId: string, signature: string, spec: PersistedIndexSpec, scopedAdapter?: HydrationPersistenceAdapter, localEnsureCompleted?: boolean): Promise<void>;
70
75
  requestApplyLocalMutations(collectionId: string, mutations: Array<PersistedMutationEnvelope>): Promise<ApplyLocalMutationsResponse>;
71
76
  requestApplyCommittedTx(collectionId: string, tx: PersistedTx, scopedAdapter?: HydrationPersistenceAdapter): Promise<ApplyCommittedTxResponse>;
77
+ reconcileCommittedTx(collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor, scopedAdapter?: HydrationPersistenceAdapter): Promise<ReconcileCommittedTxResponse>;
72
78
  pullSince(collectionId: string, fromRowVersion: number, scopedAdapter?: HydrationPersistenceAdapter): Promise<PullSinceResponse>;
73
79
  dispose(): void;
74
80
  private getOrCreateCollectionState;
@@ -105,6 +111,9 @@ export declare class BroadcastCollectionCoordinator implements PersistedCollecti
105
111
  private handleApplyCommittedTx;
106
112
  private runDeduplicatedEnvelope;
107
113
  private applyCommittedTxOnce;
114
+ private publishCommittedTx;
115
+ private handleReconcileCommittedTx;
116
+ private nextPersistedTxIfLeader;
108
117
  private applyDurablyAtNextStreamPosition;
109
118
  private handlePullSince;
110
119
  private withScheduledWriterLock;
@@ -1,5 +1,6 @@
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, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
3
4
  import { toTransportedLoadSubsetOptions, toProcessLocalLoadSubsetOptions } from "./remote-subset-wire.js";
4
5
  import { unloadRemoteSubsetOwner } from "./remote-subset-owner.js";
5
6
  const HEARTBEAT_INTERVAL_MS = 3e3;
@@ -11,6 +12,11 @@ const REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS = 2;
11
12
  const RPC_DEDUPE_RETENTION_MS = 6e4;
12
13
  const WRITER_LOCK_BUSY_RETRY_MS = 50;
13
14
  const WRITER_LOCK_MAX_RETRIES = 20;
15
+ function requireDurableElection(adapter) {
16
+ if (typeof adapter.reserveLeadershipTerm !== `function`) {
17
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
18
+ }
19
+ }
14
20
  class BroadcastCollectionCoordinator {
15
21
  constructor(options) {
16
22
  this.nodeId = safeRandomUUID();
@@ -27,6 +33,7 @@ class BroadcastCollectionCoordinator {
27
33
  this.appliedEnvelopePruneTimer = null;
28
34
  this.rejectDisposed = null;
29
35
  this.disposed = false;
36
+ if (options.adapter) requireDurableElection(options.adapter);
30
37
  this.dbName = options.dbName;
31
38
  this.coordinatorName = options.coordinatorName;
32
39
  this.defaultAdapter = options.adapter ?? null;
@@ -65,10 +72,12 @@ class BroadcastCollectionCoordinator {
65
72
  * Called by a host persistence factory to wire the internally-created adapter.
66
73
  */
67
74
  setAdapter(adapter) {
75
+ requireDurableElection(adapter);
68
76
  this.defaultAdapter = adapter;
69
77
  }
70
78
  /** Register the persistence adapter that owns one collection. */
71
79
  setAdapterForCollection(collectionId, adapter) {
80
+ requireDurableElection(adapter);
72
81
  this.collectionAdapters.set(collectionId, adapter);
73
82
  }
74
83
  registerRemoteSubsetOwner(collectionId, owner) {
@@ -347,6 +356,30 @@ class BroadcastCollectionCoordinator {
347
356
  }
348
357
  return this.sendRPC(collectionId, request);
349
358
  }
359
+ async reconcileCommittedTx(collectionId, tx, anchor, scopedAdapter) {
360
+ const initialRoute = this.waitForInitialRoute(collectionId);
361
+ if (initialRoute) await initialRoute;
362
+ let response;
363
+ for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
364
+ if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt);
365
+ const request = {
366
+ type: `rpc:reconcileCommittedTx:req`,
367
+ rpcId: safeRandomUUID(),
368
+ tx,
369
+ anchor
370
+ };
371
+ response = this.isLeader(collectionId) ? await this.handleReconcileCommittedTx(
372
+ collectionId,
373
+ request,
374
+ scopedAdapter
375
+ ) : await this.sendRPC(
376
+ collectionId,
377
+ request
378
+ );
379
+ if (response.ok || response.code !== `NOT_LEADER`) return response;
380
+ }
381
+ return response;
382
+ }
350
383
  async pullSince(collectionId, fromRowVersion, scopedAdapter) {
351
384
  if (this.isLeader(collectionId)) {
352
385
  return this.handlePullSince(
@@ -452,16 +485,18 @@ class BroadcastCollectionCoordinator {
452
485
  }
453
486
  try {
454
487
  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
- }
488
+ const reserveTerm = adapter.reserveLeadershipTerm;
489
+ if (!reserveTerm)
490
+ throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
491
+ const pos = await this.withWriterLock(
492
+ () => reserveTerm.call(adapter, collectionId, state.latestTerm)
493
+ );
494
+ state.latestTerm = pos.latestTerm;
495
+ state.latestSeq = pos.latestSeq;
496
+ state.latestRowVersion = pos.latestRowVersion;
461
497
  if (this.isDisposed() || !this.participatesInLeadership(state) || this.collections.get(collectionId) !== state || abortController.signal.aborted || state.lockAbortController !== abortController) {
462
498
  return;
463
499
  }
464
- state.latestTerm++;
465
500
  state.isLeader = true;
466
501
  state.leaderId = this.nodeId;
467
502
  this.notifyRouteWaiters(state);
@@ -718,6 +753,7 @@ class BroadcastCollectionCoordinator {
718
753
  const heartbeat = payload;
719
754
  if (typeof heartbeat.leaderId === `string` && typeof heartbeat.term === `number` && typeof heartbeat.latestSeq === `number` && typeof heartbeat.latestRowVersion === `number`) {
720
755
  const state2 = this.getOrCreateCollectionState(envelope.collectionId);
756
+ if (state2.isLeader) return;
721
757
  if (heartbeat.term < state2.latestTerm) return;
722
758
  const changedLeader = state2.leaderId !== heartbeat.leaderId;
723
759
  state2.leaderId = heartbeat.leaderId;
@@ -898,6 +934,8 @@ class BroadcastCollectionCoordinator {
898
934
  return this.handleApplyLocalMutations(collectionId, request);
899
935
  case `rpc:applyCommittedTx:req`:
900
936
  return this.handleApplyCommittedTx(collectionId, request);
937
+ case `rpc:reconcileCommittedTx:req`:
938
+ return this.handleReconcileCommittedTx(collectionId, request);
901
939
  case `rpc:pullSince:req`:
902
940
  return this.handlePullSince(collectionId, request);
903
941
  }
@@ -1391,6 +1429,10 @@ class BroadcastCollectionCoordinator {
1391
1429
  }
1392
1430
  );
1393
1431
  this.pruneAppliedEnvelopes();
1432
+ this.publishCommittedTx(collectionId, state, tx);
1433
+ return response;
1434
+ }
1435
+ publishCommittedTx(collectionId, state, tx, forceReload = false) {
1394
1436
  const committedBase = {
1395
1437
  type: `tx:committed`,
1396
1438
  term: tx.term,
@@ -1398,9 +1440,10 @@ class BroadcastCollectionCoordinator {
1398
1440
  txId: tx.txId,
1399
1441
  latestRowVersion: tx.rowVersion
1400
1442
  };
1401
- const committedPayload = tx.truncate ? {
1443
+ const committedPayload = tx.truncate || forceReload ? {
1402
1444
  ...committedBase,
1403
- requiresFullReload: true
1445
+ requiresFullReload: true,
1446
+ ...forceReload ? { reconciliationReload: true } : {}
1404
1447
  } : {
1405
1448
  ...committedBase,
1406
1449
  requiresFullReload: false,
@@ -1424,22 +1467,115 @@ class BroadcastCollectionCoordinator {
1424
1467
  for (const subscriber of state.subscribers) {
1425
1468
  subscriber(committed);
1426
1469
  }
1470
+ }
1471
+ async handleReconcileCommittedTx(collectionId, request, scopedAdapter) {
1472
+ const state = this.collections.get(collectionId);
1473
+ const notLeader = () => ({
1474
+ type: `rpc:reconcileCommittedTx:res`,
1475
+ rpcId: request.rpcId,
1476
+ ok: false,
1477
+ code: `NOT_LEADER`,
1478
+ error: `not the leader for ${collectionId}`
1479
+ });
1480
+ if (!state?.isLeader) return notLeader();
1481
+ if (!isValidCommittedTxAnchor(request.anchor) || !hasReconciliationTxPayload(request.tx)) {
1482
+ return {
1483
+ type: `rpc:reconcileCommittedTx:res`,
1484
+ rpcId: request.rpcId,
1485
+ ok: false,
1486
+ code: `UNDETERMINED`,
1487
+ error: `the source transaction has no valid durable proof`
1488
+ };
1489
+ }
1490
+ const expectedTerm = state.latestTerm;
1491
+ const result = await this.withScheduledWriterLock(
1492
+ collectionId,
1493
+ async (adapter) => {
1494
+ const candidate = this.nextPersistedTxIfLeader(
1495
+ collectionId,
1496
+ state,
1497
+ expectedTerm,
1498
+ request.tx
1499
+ );
1500
+ if (candidate === null) return null;
1501
+ if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
1502
+ try {
1503
+ const reconciled = await adapter.reconcileCommittedTx(
1504
+ collectionId,
1505
+ candidate,
1506
+ request.anchor
1507
+ );
1508
+ if (reconciled.kind !== `unknown`) {
1509
+ state.latestRowVersion = Math.max(
1510
+ state.latestRowVersion,
1511
+ reconciled.latestRowVersion
1512
+ );
1513
+ if (reconciled.kind === `applied-now`) {
1514
+ state.latestSeq = reconciled.committed.seq;
1515
+ }
1516
+ }
1517
+ return reconciled;
1518
+ } catch (error) {
1519
+ throw toPersistedCollectionDurabilityError(collectionId, error);
1520
+ }
1521
+ },
1522
+ scopedAdapter
1523
+ );
1524
+ if (result === null) return notLeader();
1525
+ if (result.kind === `unknown`) {
1526
+ return {
1527
+ type: `rpc:reconcileCommittedTx:res`,
1528
+ rpcId: request.rpcId,
1529
+ ok: false,
1530
+ code: `UNDETERMINED`,
1531
+ error: `the durable outcome cannot be certified`
1532
+ };
1533
+ }
1534
+ const current = {
1535
+ term: state.latestTerm,
1536
+ seq: state.latestSeq,
1537
+ rowVersion: state.latestRowVersion
1538
+ };
1539
+ const response = {
1540
+ type: `rpc:reconcileCommittedTx:res`,
1541
+ rpcId: request.rpcId,
1542
+ ok: true,
1543
+ alreadyApplied: result.kind === `already-applied`,
1544
+ committed: result.committed
1545
+ };
1546
+ if (!this.isDisposed() && this.collections.get(collectionId) === state) {
1547
+ this.publishCommittedTx(
1548
+ collectionId,
1549
+ state,
1550
+ result.kind === `already-applied` ? { ...request.tx, ...current } : { ...request.tx, ...result.committed },
1551
+ result.kind === `already-applied`
1552
+ );
1553
+ }
1427
1554
  return response;
1428
1555
  }
1556
+ nextPersistedTxIfLeader(collectionId, state, expectedTerm, pendingTx) {
1557
+ if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
1558
+ return null;
1559
+ }
1560
+ return {
1561
+ ...pendingTx,
1562
+ term: state.latestTerm,
1563
+ seq: state.latestSeq + 1,
1564
+ rowVersion: state.latestRowVersion + 1
1565
+ };
1566
+ }
1429
1567
  async applyDurablyAtNextStreamPosition(collectionId, state, pendingTx, scopedAdapter) {
1430
1568
  const expectedTerm = state.latestTerm;
1431
1569
  return this.withScheduledWriterLock(
1432
1570
  collectionId,
1433
1571
  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
- };
1572
+ const tx = this.nextPersistedTxIfLeader(
1573
+ collectionId,
1574
+ state,
1575
+ expectedTerm,
1576
+ pendingTx
1577
+ );
1578
+ if (tx === null) return null;
1443
1579
  try {
1444
1580
  await adapter.applyCommittedTx(collectionId, tx);
1445
1581
  } catch (error) {
@@ -1582,12 +1718,18 @@ function isRPCRequest(payload) {
1582
1718
  case `rpc:ensurePersistedIndex:req`:
1583
1719
  case `rpc:applyLocalMutations:req`:
1584
1720
  case `rpc:applyCommittedTx:req`:
1721
+ case `rpc:reconcileCommittedTx:req`:
1585
1722
  case `rpc:pullSince:req`:
1586
1723
  return true;
1587
1724
  default:
1588
1725
  return false;
1589
1726
  }
1590
1727
  }
1728
+ function hasReconciliationTxPayload(value) {
1729
+ if (!value || typeof value !== `object`) return false;
1730
+ const tx = value;
1731
+ return typeof tx.txId === `string` && Array.isArray(tx.mutations);
1732
+ }
1591
1733
  function sleep(ms) {
1592
1734
  return new Promise((resolve) => setTimeout(resolve, ms));
1593
1735
  }
@@ -1661,6 +1803,15 @@ function createRPCErrorResponse(request, cause) {
1661
1803
  code: `CONFLICT`,
1662
1804
  error
1663
1805
  };
1806
+ case `rpc:reconcileCommittedTx:req`:
1807
+ return {
1808
+ type: `rpc:reconcileCommittedTx:res`,
1809
+ rpcId: request.rpcId,
1810
+ ok: false,
1811
+ code: cause instanceof PersistedCollectionDurabilityError ? `PERSISTENCE_ERROR` : `UNDETERMINED`,
1812
+ error,
1813
+ ...cause instanceof PersistedCollectionDurabilityError ? toSafeDurabilityDetails(cause) : {}
1814
+ };
1664
1815
  case `rpc:pullSince:req`:
1665
1816
  return {
1666
1817
  type: `rpc:pullSince:res`,