@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.
@@ -49,6 +49,7 @@ export type TxCommitted = {
49
49
  latestRowVersion: number;
50
50
  } & ({
51
51
  requiresFullReload: true;
52
+ reconciliationReload?: true;
52
53
  } | {
53
54
  requiresFullReload: false;
54
55
  changedRows: Array<{
@@ -149,6 +150,41 @@ export type ApplyCommittedTxResponse = {
149
150
  sourceCode?: string | number;
150
151
  path?: string | ReadonlyArray<string | number>;
151
152
  };
153
+ /** The durable version read before a source transaction enters coordination. */
154
+ export type CommittedTxAnchor = {
155
+ latestRowVersion: number;
156
+ resetEpoch: number;
157
+ };
158
+ export type ReconcileCommittedTxResponse = {
159
+ type: `rpc:reconcileCommittedTx:res`;
160
+ rpcId: string;
161
+ ok: true;
162
+ alreadyApplied: boolean;
163
+ committed: {
164
+ term: number;
165
+ seq: number;
166
+ rowVersion: number;
167
+ };
168
+ } | {
169
+ type: `rpc:reconcileCommittedTx:res`;
170
+ rpcId: string;
171
+ ok: false;
172
+ code: `NOT_LEADER` | `UNDETERMINED` | `PERSISTENCE_ERROR`;
173
+ error: string;
174
+ sourceCode?: string | number;
175
+ path?: string | ReadonlyArray<string | number>;
176
+ };
177
+ export type ReconciledCommittedTx = {
178
+ kind: `already-applied` | `applied-now`;
179
+ committed: {
180
+ term: number;
181
+ seq: number;
182
+ rowVersion: number;
183
+ };
184
+ latestRowVersion: number;
185
+ } | {
186
+ kind: `unknown`;
187
+ };
152
188
  export type PullSinceRequest = {
153
189
  type: `rpc:pullSince:req`;
154
190
  rpcId: string;
@@ -299,6 +335,8 @@ export interface PersistenceAdapter {
299
335
  resetEpoch: number;
300
336
  }>;
301
337
  applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>;
338
+ /** Atomically certify or apply one source transaction after a lost response. */
339
+ reconcileCommittedTx?: (collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor) => Promise<ReconciledCommittedTx>;
302
340
  loadCollectionMetadata?: (collectionId: string) => Promise<Array<{
303
341
  key: string;
304
342
  value: unknown;
@@ -311,6 +349,12 @@ export interface PersistenceAdapter {
311
349
  latestSeq: number;
312
350
  latestRowVersion: number;
313
351
  }>;
352
+ /** Reserve a new durable election term before announcing Browser leadership. */
353
+ reserveLeadershipTerm?: (collectionId: string, observedTerm: number) => Promise<{
354
+ latestTerm: number;
355
+ latestSeq: number;
356
+ latestRowVersion: number;
357
+ }>;
314
358
  /**
315
359
  * Runs one complete logical hydrate as a non-preemptible scheduler unit.
316
360
  * The callback must use the supplied unscheduled adapter for all nested
@@ -365,6 +409,8 @@ export interface PersistedCollectionCoordinator {
365
409
  * loan after the call; ordinary work uses the registered collection adapter.
366
410
  */
367
411
  requestApplyCommittedTx: (collectionId: string, tx: PersistedTx, scopedAdapter?: HydrationPersistenceAdapter) => Promise<ApplyCommittedTxResponse>;
412
+ /** Available only when the elected adapter can certify the durable outcome. */
413
+ reconcileCommittedTx?: (collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor, scopedAdapter?: HydrationPersistenceAdapter) => Promise<ReconcileCommittedTxResponse>;
368
414
  /** The scoped adapter is leader-local and is never serialized to a follower. */
369
415
  pullSince?: (collectionId: string, fromRowVersion: number, scopedAdapter?: HydrationPersistenceAdapter) => Promise<PullSinceResponse>;
370
416
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const db = require("@tanstack/db");
4
4
  const errors = require("./errors.cjs");
5
+ const committedTxAnchor = require("./committed-tx-anchor.cjs");
5
6
  const persisted = require("./persisted.cjs");
6
7
  const sqliteValue = require("./sqlite-value.cjs");
7
8
  const REPLACEMENT_BATCH_SIZE = 125;
@@ -1090,6 +1091,7 @@ class SQLiteCorePersistenceAdapter {
1090
1091
  loadSubset: (collectionId, loadOptions, context) => this.loadSubsetUnscheduled(collectionId, loadOptions, context),
1091
1092
  loadResumeSnapshot: (collectionId, context) => this.loadResumeSnapshotUnscheduled(collectionId, context),
1092
1093
  applyCommittedTx: (collectionId, tx) => this.applyCommittedTxUnscheduled(collectionId, tx),
1094
+ reconcileCommittedTx: (collectionId, tx, anchor) => this.reconcileCommittedTxUnscheduled(collectionId, tx, anchor),
1093
1095
  loadCollectionMetadata: (collectionId) => this.loadCollectionMetadataUnscheduled(collectionId),
1094
1096
  scanRows: (collectionId, scanOptions) => this.scanRowsUnscheduled(collectionId, scanOptions),
1095
1097
  ensureIndex: (collectionId, signature, spec) => this.ensureIndexUnscheduled(collectionId, signature, spec),
@@ -1285,15 +1287,33 @@ class SQLiteCorePersistenceAdapter {
1285
1287
  () => this.applyCommittedTxUnscheduled(collectionId, tx)
1286
1288
  );
1287
1289
  }
1290
+ reconcileCommittedTx(collectionId, tx, anchor) {
1291
+ return this.runRegular(
1292
+ () => this.reconcileCommittedTxUnscheduled(collectionId, tx, anchor)
1293
+ );
1294
+ }
1288
1295
  async applyCommittedTxUnscheduled(collectionId, tx) {
1296
+ await this.applyCommittedTxInternal(collectionId, tx);
1297
+ }
1298
+ reconcileCommittedTxUnscheduled(collectionId, tx, anchor) {
1299
+ if (!committedTxAnchor.isValidCommittedTxAnchor(anchor)) {
1300
+ throw new Error(
1301
+ `Cannot reconcile a committed transaction without a valid durable anchor`
1302
+ );
1303
+ }
1304
+ return this.applyCommittedTxInternal(collectionId, tx, anchor);
1305
+ }
1306
+ async applyCommittedTxInternal(collectionId, tx, anchor) {
1289
1307
  const tableMapping = await this.ensureCollectionReady(collectionId);
1290
1308
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
1291
1309
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName);
1292
- await this.runInTransaction(async (transactionDriver) => {
1310
+ return this.runInTransaction(async (transactionDriver) => {
1293
1311
  const versionRows = await transactionDriver.query(
1294
1312
  `SELECT
1295
1313
  latest_row_version,
1296
1314
  key_set_evidence_available,
1315
+ (SELECT reset_epoch FROM collection_reset_epoch
1316
+ WHERE collection_id = ? LIMIT 1) AS reset_epoch,
1297
1317
  (
1298
1318
  SELECT schema_version
1299
1319
  FROM collection_registry
@@ -1308,7 +1328,14 @@ class SQLiteCorePersistenceAdapter {
1308
1328
  FROM collection_version
1309
1329
  WHERE collection_id = ?
1310
1330
  LIMIT 1`,
1311
- [collectionId, collectionId, tx.term, tx.seq, collectionId]
1331
+ [
1332
+ collectionId,
1333
+ collectionId,
1334
+ collectionId,
1335
+ tx.term,
1336
+ tx.seq,
1337
+ collectionId
1338
+ ]
1312
1339
  );
1313
1340
  const version = versionRows[0];
1314
1341
  if (!version) {
@@ -1321,8 +1348,34 @@ class SQLiteCorePersistenceAdapter {
1321
1348
  `Schema version mismatch for collection "${collectionId}": found ${version.schema_version}, expected ${this.schemaVersion}. Refusing to apply a committed transaction through a stale cached adapter.`
1322
1349
  );
1323
1350
  }
1351
+ if (anchor) {
1352
+ const sameResetEpoch = (version.reset_epoch ?? 0) === anchor.resetEpoch;
1353
+ const matching = await transactionDriver.query(
1354
+ `SELECT term, seq, row_version FROM applied_tx
1355
+ WHERE collection_id = ? AND tx_id = ? LIMIT 2`,
1356
+ [collectionId, tx.txId]
1357
+ );
1358
+ if (matching.length > 1) return { kind: `unknown` };
1359
+ if (matching[0]) {
1360
+ if (matching[0].row_version <= anchor.latestRowVersion || !sameResetEpoch) {
1361
+ return { kind: `unknown` };
1362
+ }
1363
+ return {
1364
+ kind: `already-applied`,
1365
+ committed: {
1366
+ term: matching[0].term,
1367
+ seq: matching[0].seq,
1368
+ rowVersion: matching[0].row_version
1369
+ },
1370
+ latestRowVersion: version.latest_row_version
1371
+ };
1372
+ }
1373
+ if (version.latest_row_version !== anchor.latestRowVersion || !sameResetEpoch) {
1374
+ return { kind: `unknown` };
1375
+ }
1376
+ }
1324
1377
  if (version.already_applied === 1) {
1325
- return;
1378
+ return { kind: `unknown` };
1326
1379
  }
1327
1380
  const currentRowVersion = version.latest_row_version;
1328
1381
  const nextRowVersion = Math.max(currentRowVersion + 1, tx.rowVersion);
@@ -1570,6 +1623,15 @@ class SQLiteCorePersistenceAdapter {
1570
1623
  ]
1571
1624
  );
1572
1625
  await this.pruneAppliedTxRows(collectionId, transactionDriver);
1626
+ return {
1627
+ kind: `applied-now`,
1628
+ committed: {
1629
+ term: tx.term,
1630
+ seq: tx.seq,
1631
+ rowVersion: nextRowVersion
1632
+ },
1633
+ latestRowVersion: nextRowVersion
1634
+ };
1573
1635
  });
1574
1636
  }
1575
1637
  loadCollectionMetadata(collectionId) {
@@ -1734,6 +1796,31 @@ class SQLiteCorePersistenceAdapter {
1734
1796
  }
1735
1797
  });
1736
1798
  }
1799
+ reserveLeadershipTerm(collectionId, observedTerm) {
1800
+ return this.reserveLeadershipTermUnscheduled(collectionId, observedTerm);
1801
+ }
1802
+ async reserveLeadershipTermUnscheduled(collectionId, observedTerm) {
1803
+ await this.ensureCollectionReady(collectionId);
1804
+ return this.runInTransaction(async (transactionDriver) => {
1805
+ await this.assertCurrentSchemaVersion(
1806
+ collectionId,
1807
+ transactionDriver,
1808
+ `reserve a leadership term`
1809
+ );
1810
+ await transactionDriver.run(
1811
+ `INSERT INTO leader_term (collection_id, latest_term)
1812
+ VALUES (?, ? + 1)
1813
+ ON CONFLICT(collection_id) DO UPDATE SET
1814
+ latest_term = MAX(leader_term.latest_term, ?) + 1`,
1815
+ [collectionId, observedTerm, observedTerm]
1816
+ );
1817
+ const [position, latestRowVersion] = await Promise.all([
1818
+ this.readStreamPosition(collectionId, transactionDriver),
1819
+ this.readLatestRowVersion(collectionId, transactionDriver)
1820
+ ]);
1821
+ return { ...position, latestRowVersion };
1822
+ });
1823
+ }
1737
1824
  getStreamPosition(collectionId) {
1738
1825
  return this.getStreamPositionUnscheduled(collectionId);
1739
1826
  }
@@ -2357,6 +2444,10 @@ class SQLiteCorePersistenceAdapter {
2357
2444
  PRIMARY KEY (collection_id, term, seq)
2358
2445
  )`
2359
2446
  );
2447
+ await this.driver.exec(
2448
+ `CREATE INDEX IF NOT EXISTS idx_applied_tx_collection_tx_id
2449
+ ON applied_tx (collection_id, tx_id)`
2450
+ );
2360
2451
  try {
2361
2452
  await this.driver.exec(
2362
2453
  `ALTER TABLE applied_tx ADD COLUMN replay_json TEXT`