@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.
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/db-sqlite-persistence-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "SQLite persisted collection core for TanStack DB",
|
|
5
5
|
"author": "TanStack Team",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
],
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@standard-schema/spec": "^1.1.0",
|
|
53
|
-
"@tanstack/db": "0.
|
|
53
|
+
"@tanstack/db": "0.13.0"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"typescript": ">=4.7"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { safeRandomUUID } from '@tanstack/db'
|
|
2
|
+
import { isValidCommittedTxAnchor } from './committed-tx-anchor'
|
|
2
3
|
import {
|
|
3
4
|
DuplicateRemoteSubsetOwnerError,
|
|
4
5
|
IndeterminateCommitError,
|
|
6
|
+
InvalidPersistenceAdapterError,
|
|
5
7
|
PersistedCollectionDurabilityError,
|
|
6
8
|
RetryableRemoteSubsetAcquisitionError,
|
|
7
9
|
toPersistedCollectionDurabilityError,
|
|
@@ -18,6 +20,7 @@ import type { RemoteSubsetOwner } from './remote-subset-owner'
|
|
|
18
20
|
import type {
|
|
19
21
|
ApplyCommittedTxResponse,
|
|
20
22
|
ApplyLocalMutationsResponse,
|
|
23
|
+
CommittedTxAnchor,
|
|
21
24
|
EnsureRemoteSubsetRequest,
|
|
22
25
|
EnsureRemoteSubsetResponse,
|
|
23
26
|
HydrationPersistenceAdapter,
|
|
@@ -29,6 +32,7 @@ import type {
|
|
|
29
32
|
PersistenceAdapter,
|
|
30
33
|
ProtocolEnvelope,
|
|
31
34
|
PullSinceResponse,
|
|
35
|
+
ReconcileCommittedTxResponse,
|
|
32
36
|
ReleaseRemoteSubsetRequest,
|
|
33
37
|
ReleaseRemoteSubsetResponse,
|
|
34
38
|
TxCommitted,
|
|
@@ -73,6 +77,12 @@ type RPCRequest =
|
|
|
73
77
|
envelopeId: string
|
|
74
78
|
tx: PersistedTx
|
|
75
79
|
}
|
|
80
|
+
| {
|
|
81
|
+
type: `rpc:reconcileCommittedTx:req`
|
|
82
|
+
rpcId: string
|
|
83
|
+
tx: PersistedTx
|
|
84
|
+
anchor: CommittedTxAnchor
|
|
85
|
+
}
|
|
76
86
|
| {
|
|
77
87
|
type: `rpc:pullSince:req`
|
|
78
88
|
rpcId: string
|
|
@@ -90,6 +100,7 @@ type RPCResponse =
|
|
|
90
100
|
}
|
|
91
101
|
| ApplyLocalMutationsResponse
|
|
92
102
|
| ApplyCommittedTxResponse
|
|
103
|
+
| ReconcileCommittedTxResponse
|
|
93
104
|
| PullSinceResponse
|
|
94
105
|
|
|
95
106
|
type PendingRPC = {
|
|
@@ -115,7 +126,11 @@ type CollectionState = {
|
|
|
115
126
|
// belong to the persisted sync wrapper, not the elected writer transport.
|
|
116
127
|
type CoordinatorAdapter = Pick<
|
|
117
128
|
PersistenceAdapter,
|
|
118
|
-
|
|
129
|
+
| `loadSubset`
|
|
130
|
+
| `applyCommittedTx`
|
|
131
|
+
| `reconcileCommittedTx`
|
|
132
|
+
| `ensureIndex`
|
|
133
|
+
| `runInRegularScope`
|
|
119
134
|
> & {
|
|
120
135
|
pullSince?: (
|
|
121
136
|
collectionId: string,
|
|
@@ -137,6 +152,20 @@ type CoordinatorAdapter = Pick<
|
|
|
137
152
|
latestSeq: number
|
|
138
153
|
latestRowVersion: number
|
|
139
154
|
}>
|
|
155
|
+
reserveLeadershipTerm?: (
|
|
156
|
+
collectionId: string,
|
|
157
|
+
observedTerm: number,
|
|
158
|
+
) => Promise<{
|
|
159
|
+
latestTerm: number
|
|
160
|
+
latestSeq: number
|
|
161
|
+
latestRowVersion: number
|
|
162
|
+
}>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function requireDurableElection(adapter: CoordinatorAdapter): void {
|
|
166
|
+
if (typeof adapter.reserveLeadershipTerm !== `function`) {
|
|
167
|
+
throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`)
|
|
168
|
+
}
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
type ActiveRemoteSubsetAcquisition = {
|
|
@@ -282,6 +311,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
282
311
|
}
|
|
283
312
|
|
|
284
313
|
constructor(options: BroadcastCollectionCoordinatorOptions) {
|
|
314
|
+
if (options.adapter) requireDurableElection(options.adapter)
|
|
285
315
|
this.dbName = options.dbName
|
|
286
316
|
this.coordinatorName = options.coordinatorName
|
|
287
317
|
this.defaultAdapter = options.adapter ?? null
|
|
@@ -300,6 +330,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
300
330
|
* Called by a host persistence factory to wire the internally-created adapter.
|
|
301
331
|
*/
|
|
302
332
|
setAdapter(adapter: CoordinatorAdapter): void {
|
|
333
|
+
requireDurableElection(adapter)
|
|
303
334
|
this.defaultAdapter = adapter
|
|
304
335
|
}
|
|
305
336
|
|
|
@@ -308,6 +339,7 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
308
339
|
collectionId: string,
|
|
309
340
|
adapter: CoordinatorAdapter,
|
|
310
341
|
): void {
|
|
342
|
+
requireDurableElection(adapter)
|
|
311
343
|
this.collectionAdapters.set(collectionId, adapter)
|
|
312
344
|
}
|
|
313
345
|
|
|
@@ -677,6 +709,43 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
677
709
|
return this.sendRPC<ApplyCommittedTxResponse>(collectionId, request)
|
|
678
710
|
}
|
|
679
711
|
|
|
712
|
+
async reconcileCommittedTx(
|
|
713
|
+
collectionId: string,
|
|
714
|
+
tx: PersistedTx,
|
|
715
|
+
anchor: CommittedTxAnchor,
|
|
716
|
+
scopedAdapter?: HydrationPersistenceAdapter,
|
|
717
|
+
): Promise<ReconcileCommittedTxResponse> {
|
|
718
|
+
const initialRoute = this.waitForInitialRoute(collectionId)
|
|
719
|
+
if (initialRoute) await initialRoute
|
|
720
|
+
// Each attempt checks the exact txId under the elected writer lock, so a
|
|
721
|
+
// stale NOT_LEADER reply can follow the replacement route safely.
|
|
722
|
+
let response: ReconcileCommittedTxResponse | undefined
|
|
723
|
+
for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
|
|
724
|
+
if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt)
|
|
725
|
+
const request: Extract<
|
|
726
|
+
RPCRequest,
|
|
727
|
+
{ type: `rpc:reconcileCommittedTx:req` }
|
|
728
|
+
> = {
|
|
729
|
+
type: `rpc:reconcileCommittedTx:req`,
|
|
730
|
+
rpcId: safeRandomUUID(),
|
|
731
|
+
tx,
|
|
732
|
+
anchor,
|
|
733
|
+
}
|
|
734
|
+
response = this.isLeader(collectionId)
|
|
735
|
+
? await this.handleReconcileCommittedTx(
|
|
736
|
+
collectionId,
|
|
737
|
+
request,
|
|
738
|
+
scopedAdapter,
|
|
739
|
+
)
|
|
740
|
+
: await this.sendRPC<ReconcileCommittedTxResponse>(
|
|
741
|
+
collectionId,
|
|
742
|
+
request,
|
|
743
|
+
)
|
|
744
|
+
if (response.ok || response.code !== `NOT_LEADER`) return response
|
|
745
|
+
}
|
|
746
|
+
return response!
|
|
747
|
+
}
|
|
748
|
+
|
|
680
749
|
async pullSince(
|
|
681
750
|
collectionId: string,
|
|
682
751
|
fromRowVersion: number,
|
|
@@ -806,14 +875,20 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
806
875
|
}
|
|
807
876
|
|
|
808
877
|
try {
|
|
809
|
-
//
|
|
878
|
+
// Reserve an election term durably before advertising this route.
|
|
879
|
+
// Election bypasses the shared scheduler because a hydrate may be
|
|
880
|
+
// waiting for its first writer route. The writer Web Lock orders
|
|
881
|
+
// reservation after any old owner's in-flight durable write.
|
|
810
882
|
const adapter = this.requireAdapter(collectionId)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
883
|
+
const reserveTerm = adapter.reserveLeadershipTerm
|
|
884
|
+
if (!reserveTerm)
|
|
885
|
+
throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`)
|
|
886
|
+
const pos = await this.withWriterLock(() =>
|
|
887
|
+
reserveTerm.call(adapter, collectionId, state.latestTerm),
|
|
888
|
+
)
|
|
889
|
+
state.latestTerm = pos.latestTerm
|
|
890
|
+
state.latestSeq = pos.latestSeq
|
|
891
|
+
state.latestRowVersion = pos.latestRowVersion
|
|
817
892
|
|
|
818
893
|
if (
|
|
819
894
|
this.isDisposed() ||
|
|
@@ -825,7 +900,6 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
825
900
|
return
|
|
826
901
|
}
|
|
827
902
|
|
|
828
|
-
state.latestTerm++
|
|
829
903
|
state.isLeader = true
|
|
830
904
|
state.leaderId = this.nodeId
|
|
831
905
|
this.notifyRouteWaiters(state)
|
|
@@ -1175,6 +1249,9 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
1175
1249
|
typeof heartbeat.latestRowVersion === `number`
|
|
1176
1250
|
) {
|
|
1177
1251
|
const state = this.getOrCreateCollectionState(envelope.collectionId)
|
|
1252
|
+
// The local Web Lock owns this route even if an older heartbeat arrives
|
|
1253
|
+
// while leadership changes.
|
|
1254
|
+
if (state.isLeader) return
|
|
1178
1255
|
if (heartbeat.term < state.latestTerm) return
|
|
1179
1256
|
const changedLeader = state.leaderId !== heartbeat.leaderId
|
|
1180
1257
|
state.leaderId = heartbeat.leaderId
|
|
@@ -1415,6 +1492,8 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
1415
1492
|
return this.handleApplyLocalMutations(collectionId, request)
|
|
1416
1493
|
case `rpc:applyCommittedTx:req`:
|
|
1417
1494
|
return this.handleApplyCommittedTx(collectionId, request)
|
|
1495
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
1496
|
+
return this.handleReconcileCommittedTx(collectionId, request)
|
|
1418
1497
|
case `rpc:pullSince:req`:
|
|
1419
1498
|
return this.handlePullSince(collectionId, request)
|
|
1420
1499
|
}
|
|
@@ -2056,6 +2135,17 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
2056
2135
|
)
|
|
2057
2136
|
this.pruneAppliedEnvelopes()
|
|
2058
2137
|
|
|
2138
|
+
this.publishCommittedTx(collectionId, state, tx)
|
|
2139
|
+
|
|
2140
|
+
return response
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
private publishCommittedTx(
|
|
2144
|
+
collectionId: string,
|
|
2145
|
+
state: CollectionState,
|
|
2146
|
+
tx: PersistedTx,
|
|
2147
|
+
forceReload = false,
|
|
2148
|
+
): void {
|
|
2059
2149
|
const committedBase = {
|
|
2060
2150
|
type: `tx:committed` as const,
|
|
2061
2151
|
term: tx.term,
|
|
@@ -2063,26 +2153,28 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
2063
2153
|
txId: tx.txId,
|
|
2064
2154
|
latestRowVersion: tx.rowVersion,
|
|
2065
2155
|
}
|
|
2066
|
-
const committedPayload: TxCommitted =
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
.
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2156
|
+
const committedPayload: TxCommitted =
|
|
2157
|
+
tx.truncate || forceReload
|
|
2158
|
+
? {
|
|
2159
|
+
...committedBase,
|
|
2160
|
+
requiresFullReload: true,
|
|
2161
|
+
...(forceReload ? { reconciliationReload: true as const } : {}),
|
|
2162
|
+
}
|
|
2163
|
+
: {
|
|
2164
|
+
...committedBase,
|
|
2165
|
+
requiresFullReload: false,
|
|
2166
|
+
changedRows: tx.mutations
|
|
2167
|
+
.filter((mutation) => mutation.type !== `delete`)
|
|
2168
|
+
.map((mutation) => ({
|
|
2169
|
+
key: mutation.key,
|
|
2170
|
+
value: mutation.value,
|
|
2171
|
+
})),
|
|
2172
|
+
deletedKeys: tx.mutations
|
|
2173
|
+
.filter((mutation) => mutation.type === `delete`)
|
|
2174
|
+
.map((mutation) => mutation.key),
|
|
2175
|
+
rowMetadataMutations: tx.rowMetadataMutations,
|
|
2176
|
+
collectionMetadataMutations: tx.collectionMetadataMutations,
|
|
2177
|
+
}
|
|
2086
2178
|
const committed: ProtocolEnvelope<TxCommitted> = {
|
|
2087
2179
|
v: 1,
|
|
2088
2180
|
dbName: this.dbName,
|
|
@@ -2095,10 +2187,128 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
2095
2187
|
for (const subscriber of state.subscribers) {
|
|
2096
2188
|
subscriber(committed)
|
|
2097
2189
|
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
private async handleReconcileCommittedTx(
|
|
2193
|
+
collectionId: string,
|
|
2194
|
+
request: Extract<RPCRequest, { type: `rpc:reconcileCommittedTx:req` }>,
|
|
2195
|
+
scopedAdapter?: HydrationPersistenceAdapter,
|
|
2196
|
+
): Promise<ReconcileCommittedTxResponse> {
|
|
2197
|
+
const state = this.collections.get(collectionId)
|
|
2198
|
+
const notLeader = (): ReconcileCommittedTxResponse => ({
|
|
2199
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
2200
|
+
rpcId: request.rpcId,
|
|
2201
|
+
ok: false,
|
|
2202
|
+
code: `NOT_LEADER`,
|
|
2203
|
+
error: `not the leader for ${collectionId}`,
|
|
2204
|
+
})
|
|
2205
|
+
if (!state?.isLeader) return notLeader()
|
|
2206
|
+
if (
|
|
2207
|
+
!isValidCommittedTxAnchor(request.anchor) ||
|
|
2208
|
+
!hasReconciliationTxPayload(request.tx)
|
|
2209
|
+
) {
|
|
2210
|
+
return {
|
|
2211
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
2212
|
+
rpcId: request.rpcId,
|
|
2213
|
+
ok: false,
|
|
2214
|
+
code: `UNDETERMINED`,
|
|
2215
|
+
error: `the source transaction has no valid durable proof`,
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2098
2218
|
|
|
2219
|
+
const expectedTerm = state.latestTerm
|
|
2220
|
+
const result = await this.withScheduledWriterLock(
|
|
2221
|
+
collectionId,
|
|
2222
|
+
async (adapter) => {
|
|
2223
|
+
const candidate = this.nextPersistedTxIfLeader(
|
|
2224
|
+
collectionId,
|
|
2225
|
+
state,
|
|
2226
|
+
expectedTerm,
|
|
2227
|
+
request.tx,
|
|
2228
|
+
)
|
|
2229
|
+
if (candidate === null) return null
|
|
2230
|
+
if (!adapter.reconcileCommittedTx) return { kind: `unknown` as const }
|
|
2231
|
+
try {
|
|
2232
|
+
const reconciled = await adapter.reconcileCommittedTx(
|
|
2233
|
+
collectionId,
|
|
2234
|
+
candidate,
|
|
2235
|
+
request.anchor,
|
|
2236
|
+
)
|
|
2237
|
+
if (reconciled.kind !== `unknown`) {
|
|
2238
|
+
state.latestRowVersion = Math.max(
|
|
2239
|
+
state.latestRowVersion,
|
|
2240
|
+
reconciled.latestRowVersion,
|
|
2241
|
+
)
|
|
2242
|
+
if (reconciled.kind === `applied-now`) {
|
|
2243
|
+
state.latestSeq = reconciled.committed.seq
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
return reconciled
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
throw toPersistedCollectionDurabilityError(collectionId, error)
|
|
2249
|
+
}
|
|
2250
|
+
},
|
|
2251
|
+
scopedAdapter,
|
|
2252
|
+
)
|
|
2253
|
+
if (result === null) return notLeader()
|
|
2254
|
+
if (result.kind === `unknown`) {
|
|
2255
|
+
return {
|
|
2256
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
2257
|
+
rpcId: request.rpcId,
|
|
2258
|
+
ok: false,
|
|
2259
|
+
code: `UNDETERMINED`,
|
|
2260
|
+
error: `the durable outcome cannot be certified`,
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
const current = {
|
|
2265
|
+
term: state.latestTerm,
|
|
2266
|
+
seq: state.latestSeq,
|
|
2267
|
+
rowVersion: state.latestRowVersion,
|
|
2268
|
+
}
|
|
2269
|
+
const response: ReconcileCommittedTxResponse = {
|
|
2270
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
2271
|
+
rpcId: request.rpcId,
|
|
2272
|
+
ok: true,
|
|
2273
|
+
alreadyApplied: result.kind === `already-applied`,
|
|
2274
|
+
committed: result.committed,
|
|
2275
|
+
}
|
|
2276
|
+
if (!this.isDisposed() && this.collections.get(collectionId) === state) {
|
|
2277
|
+
this.publishCommittedTx(
|
|
2278
|
+
collectionId,
|
|
2279
|
+
state,
|
|
2280
|
+
result.kind === `already-applied`
|
|
2281
|
+
? { ...request.tx, ...current }
|
|
2282
|
+
: { ...request.tx, ...result.committed },
|
|
2283
|
+
result.kind === `already-applied`,
|
|
2284
|
+
)
|
|
2285
|
+
}
|
|
2099
2286
|
return response
|
|
2100
2287
|
}
|
|
2101
2288
|
|
|
2289
|
+
private nextPersistedTxIfLeader(
|
|
2290
|
+
collectionId: string,
|
|
2291
|
+
state: CollectionState,
|
|
2292
|
+
expectedTerm: number,
|
|
2293
|
+
pendingTx: Omit<PersistedTx, `term` | `seq` | `rowVersion`>,
|
|
2294
|
+
): PersistedTx | null {
|
|
2295
|
+
if (
|
|
2296
|
+
this.isDisposed() ||
|
|
2297
|
+
this.collections.get(collectionId) !== state ||
|
|
2298
|
+
!state.isLeader ||
|
|
2299
|
+
state.leaderId !== this.nodeId ||
|
|
2300
|
+
state.latestTerm !== expectedTerm
|
|
2301
|
+
) {
|
|
2302
|
+
return null
|
|
2303
|
+
}
|
|
2304
|
+
return {
|
|
2305
|
+
...pendingTx,
|
|
2306
|
+
term: state.latestTerm,
|
|
2307
|
+
seq: state.latestSeq + 1,
|
|
2308
|
+
rowVersion: state.latestRowVersion + 1,
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2102
2312
|
private async applyDurablyAtNextStreamPosition(
|
|
2103
2313
|
collectionId: string,
|
|
2104
2314
|
state: CollectionState,
|
|
@@ -2109,21 +2319,13 @@ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordi
|
|
|
2109
2319
|
return this.withScheduledWriterLock(
|
|
2110
2320
|
collectionId,
|
|
2111
2321
|
async (adapter) => {
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
)
|
|
2119
|
-
return null
|
|
2120
|
-
}
|
|
2121
|
-
const tx: PersistedTx = {
|
|
2122
|
-
...pendingTx,
|
|
2123
|
-
term: state.latestTerm,
|
|
2124
|
-
seq: state.latestSeq + 1,
|
|
2125
|
-
rowVersion: state.latestRowVersion + 1,
|
|
2126
|
-
}
|
|
2322
|
+
const tx = this.nextPersistedTxIfLeader(
|
|
2323
|
+
collectionId,
|
|
2324
|
+
state,
|
|
2325
|
+
expectedTerm,
|
|
2326
|
+
pendingTx,
|
|
2327
|
+
)
|
|
2328
|
+
if (tx === null) return null
|
|
2127
2329
|
try {
|
|
2128
2330
|
await adapter.applyCommittedTx(collectionId, tx)
|
|
2129
2331
|
} catch (error) {
|
|
@@ -2315,6 +2517,7 @@ function isRPCRequest(payload: unknown): payload is RPCRequest {
|
|
|
2315
2517
|
case `rpc:ensurePersistedIndex:req`:
|
|
2316
2518
|
case `rpc:applyLocalMutations:req`:
|
|
2317
2519
|
case `rpc:applyCommittedTx:req`:
|
|
2520
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
2318
2521
|
case `rpc:pullSince:req`:
|
|
2319
2522
|
return true
|
|
2320
2523
|
default:
|
|
@@ -2322,6 +2525,12 @@ function isRPCRequest(payload: unknown): payload is RPCRequest {
|
|
|
2322
2525
|
}
|
|
2323
2526
|
}
|
|
2324
2527
|
|
|
2528
|
+
function hasReconciliationTxPayload(value: unknown): boolean {
|
|
2529
|
+
if (!value || typeof value !== `object`) return false
|
|
2530
|
+
const tx = value as Record<string, unknown>
|
|
2531
|
+
return typeof tx.txId === `string` && Array.isArray(tx.mutations)
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2325
2534
|
function sleep(ms: number): Promise<void> {
|
|
2326
2535
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
2327
2536
|
}
|
|
@@ -2411,6 +2620,20 @@ function createRPCErrorResponse(
|
|
|
2411
2620
|
code: `CONFLICT`,
|
|
2412
2621
|
error,
|
|
2413
2622
|
}
|
|
2623
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
2624
|
+
return {
|
|
2625
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
2626
|
+
rpcId: request.rpcId,
|
|
2627
|
+
ok: false,
|
|
2628
|
+
code:
|
|
2629
|
+
cause instanceof PersistedCollectionDurabilityError
|
|
2630
|
+
? `PERSISTENCE_ERROR`
|
|
2631
|
+
: `UNDETERMINED`,
|
|
2632
|
+
error,
|
|
2633
|
+
...(cause instanceof PersistedCollectionDurabilityError
|
|
2634
|
+
? toSafeDurabilityDetails(cause)
|
|
2635
|
+
: {}),
|
|
2636
|
+
}
|
|
2414
2637
|
case `rpc:pullSince:req`:
|
|
2415
2638
|
return {
|
|
2416
2639
|
type: `rpc:pullSince:res`,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// RPC payloads and direct adapter calls can bypass TypeScript's anchor type.
|
|
2
|
+
export function isValidCommittedTxAnchor(value: unknown): boolean {
|
|
3
|
+
if (!value || typeof value !== `object`) return false
|
|
4
|
+
const anchor = value as Record<string, unknown>
|
|
5
|
+
return (
|
|
6
|
+
typeof anchor.latestRowVersion === `number` &&
|
|
7
|
+
Number.isSafeInteger(anchor.latestRowVersion) &&
|
|
8
|
+
anchor.latestRowVersion >= 0 &&
|
|
9
|
+
typeof anchor.resetEpoch === `number` &&
|
|
10
|
+
Number.isSafeInteger(anchor.resetEpoch) &&
|
|
11
|
+
anchor.resetEpoch >= 0
|
|
12
|
+
)
|
|
13
|
+
}
|