@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 +22 -2
- package/dist/cjs/broadcast-coordinator.cjs +169 -18
- package/dist/cjs/broadcast-coordinator.cjs.map +1 -1
- package/dist/cjs/broadcast-coordinator.d.cts +11 -2
- package/dist/cjs/committed-tx-anchor.cjs +9 -0
- package/dist/cjs/committed-tx-anchor.cjs.map +1 -0
- package/dist/cjs/committed-tx-anchor.d.cts +1 -0
- package/dist/cjs/persisted.cjs +199 -43
- package/dist/cjs/persisted.cjs.map +1 -1
- package/dist/cjs/persisted.d.cts +46 -0
- package/dist/cjs/sqlite-core-adapter.cjs +94 -3
- package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
- package/dist/cjs/sqlite-core-adapter.d.cts +10 -1
- package/dist/esm/broadcast-coordinator.d.ts +11 -2
- package/dist/esm/broadcast-coordinator.js +170 -19
- package/dist/esm/broadcast-coordinator.js.map +1 -1
- package/dist/esm/committed-tx-anchor.d.ts +1 -0
- package/dist/esm/committed-tx-anchor.js +9 -0
- package/dist/esm/committed-tx-anchor.js.map +1 -0
- package/dist/esm/persisted.d.ts +46 -0
- package/dist/esm/persisted.js +201 -45
- package/dist/esm/persisted.js.map +1 -1
- package/dist/esm/sqlite-core-adapter.d.ts +10 -1
- package/dist/esm/sqlite-core-adapter.js +94 -3
- package/dist/esm/sqlite-core-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/broadcast-coordinator.ts +267 -44
- package/src/committed-tx-anchor.ts +13 -0
- package/src/persisted.ts +315 -53
- package/src/sqlite-core-adapter.ts +143 -3
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
|
|
176
|
-
reconciliation. Coordinators never retry
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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`,
|