@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/db-sqlite-persistence-core",
3
- "version": "0.4.7",
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.12.3"
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
- `loadSubset` | `applyCommittedTx` | `ensureIndex` | `runInRegularScope`
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
- // Restore stream position from DB before claiming leadership
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
- if (adapter.getStreamPosition) {
812
- const pos = await adapter.getStreamPosition(collectionId)
813
- state.latestTerm = pos.latestTerm
814
- state.latestSeq = pos.latestSeq
815
- state.latestRowVersion = pos.latestRowVersion
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 = tx.truncate
2067
- ? {
2068
- ...committedBase,
2069
- requiresFullReload: true,
2070
- }
2071
- : {
2072
- ...committedBase,
2073
- requiresFullReload: false,
2074
- changedRows: tx.mutations
2075
- .filter((mutation) => mutation.type !== `delete`)
2076
- .map((mutation) => ({
2077
- key: mutation.key,
2078
- value: mutation.value,
2079
- })),
2080
- deletedKeys: tx.mutations
2081
- .filter((mutation) => mutation.type === `delete`)
2082
- .map((mutation) => mutation.key),
2083
- rowMetadataMutations: tx.rowMetadataMutations,
2084
- collectionMetadataMutations: tx.collectionMetadataMutations,
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
- if (
2113
- this.isDisposed() ||
2114
- this.collections.get(collectionId) !== state ||
2115
- !state.isLeader ||
2116
- state.leaderId !== this.nodeId ||
2117
- state.latestTerm !== expectedTerm
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
+ }