@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/src/persisted.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SYNC_PERSISTENCE_VERSION,
|
|
7
7
|
SyncTransactionAbortedError,
|
|
8
8
|
compileSingleRowExpression,
|
|
9
|
+
equalPersistedSnapshotValues,
|
|
9
10
|
getLoadSubsetDemandKey,
|
|
10
11
|
safeRandomUUID,
|
|
11
12
|
toBooleanPredicate,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
} from '@tanstack/db'
|
|
16
17
|
import {
|
|
17
18
|
DuplicateRemoteSubsetOwnerError,
|
|
19
|
+
IndeterminateCommitError,
|
|
18
20
|
InvalidPersistedCollectionConfigError,
|
|
19
21
|
InvalidPersistedCollectionCoordinatorError,
|
|
20
22
|
InvalidPersistedStorageKeyEncodingError,
|
|
@@ -115,6 +117,7 @@ export type TxCommitted = {
|
|
|
115
117
|
} & (
|
|
116
118
|
| {
|
|
117
119
|
requiresFullReload: true
|
|
120
|
+
reconciliationReload?: true
|
|
118
121
|
}
|
|
119
122
|
| {
|
|
120
123
|
requiresFullReload: false
|
|
@@ -238,6 +241,38 @@ export type ApplyCommittedTxResponse =
|
|
|
238
241
|
path?: string | ReadonlyArray<string | number>
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
/** The durable version read before a source transaction enters coordination. */
|
|
245
|
+
export type CommittedTxAnchor = {
|
|
246
|
+
latestRowVersion: number
|
|
247
|
+
resetEpoch: number
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type ReconcileCommittedTxResponse =
|
|
251
|
+
| {
|
|
252
|
+
type: `rpc:reconcileCommittedTx:res`
|
|
253
|
+
rpcId: string
|
|
254
|
+
ok: true
|
|
255
|
+
alreadyApplied: boolean
|
|
256
|
+
committed: { term: number; seq: number; rowVersion: number }
|
|
257
|
+
}
|
|
258
|
+
| {
|
|
259
|
+
type: `rpc:reconcileCommittedTx:res`
|
|
260
|
+
rpcId: string
|
|
261
|
+
ok: false
|
|
262
|
+
code: `NOT_LEADER` | `UNDETERMINED` | `PERSISTENCE_ERROR`
|
|
263
|
+
error: string
|
|
264
|
+
sourceCode?: string | number
|
|
265
|
+
path?: string | ReadonlyArray<string | number>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type ReconciledCommittedTx =
|
|
269
|
+
| {
|
|
270
|
+
kind: `already-applied` | `applied-now`
|
|
271
|
+
committed: { term: number; seq: number; rowVersion: number }
|
|
272
|
+
latestRowVersion: number
|
|
273
|
+
}
|
|
274
|
+
| { kind: `unknown` }
|
|
275
|
+
|
|
241
276
|
export type PullSinceRequest = {
|
|
242
277
|
type: `rpc:pullSince:req`
|
|
243
278
|
rpcId: string
|
|
@@ -418,6 +453,12 @@ export interface PersistenceAdapter {
|
|
|
418
453
|
resetEpoch: number
|
|
419
454
|
}>
|
|
420
455
|
applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>
|
|
456
|
+
/** Atomically certify or apply one source transaction after a lost response. */
|
|
457
|
+
reconcileCommittedTx?: (
|
|
458
|
+
collectionId: string,
|
|
459
|
+
tx: PersistedTx,
|
|
460
|
+
anchor: CommittedTxAnchor,
|
|
461
|
+
) => Promise<ReconciledCommittedTx>
|
|
421
462
|
loadCollectionMetadata?: (
|
|
422
463
|
collectionId: string,
|
|
423
464
|
) => Promise<Array<{ key: string; value: unknown }>>
|
|
@@ -436,6 +477,15 @@ export interface PersistenceAdapter {
|
|
|
436
477
|
latestSeq: number
|
|
437
478
|
latestRowVersion: number
|
|
438
479
|
}>
|
|
480
|
+
/** Reserve a new durable election term before announcing Browser leadership. */
|
|
481
|
+
reserveLeadershipTerm?: (
|
|
482
|
+
collectionId: string,
|
|
483
|
+
observedTerm: number,
|
|
484
|
+
) => Promise<{
|
|
485
|
+
latestTerm: number
|
|
486
|
+
latestSeq: number
|
|
487
|
+
latestRowVersion: number
|
|
488
|
+
}>
|
|
439
489
|
/**
|
|
440
490
|
* Runs one complete logical hydrate as a non-preemptible scheduler unit.
|
|
441
491
|
* The callback must use the supplied unscheduled adapter for all nested
|
|
@@ -555,6 +605,13 @@ export interface PersistedCollectionCoordinator {
|
|
|
555
605
|
tx: PersistedTx,
|
|
556
606
|
scopedAdapter?: HydrationPersistenceAdapter,
|
|
557
607
|
) => Promise<ApplyCommittedTxResponse>
|
|
608
|
+
/** Available only when the elected adapter can certify the durable outcome. */
|
|
609
|
+
reconcileCommittedTx?: (
|
|
610
|
+
collectionId: string,
|
|
611
|
+
tx: PersistedTx,
|
|
612
|
+
anchor: CommittedTxAnchor,
|
|
613
|
+
scopedAdapter?: HydrationPersistenceAdapter,
|
|
614
|
+
) => Promise<ReconcileCommittedTxResponse>
|
|
558
615
|
/** The scoped adapter is leader-local and is never serialized to a follower. */
|
|
559
616
|
pullSince?: (
|
|
560
617
|
collectionId: string,
|
|
@@ -1252,7 +1309,10 @@ function isTxCommittedPayload(payload: unknown): payload is TxCommitted {
|
|
|
1252
1309
|
}
|
|
1253
1310
|
|
|
1254
1311
|
if (payload.requiresFullReload) {
|
|
1255
|
-
return
|
|
1312
|
+
return (
|
|
1313
|
+
payload.reconciliationReload === undefined ||
|
|
1314
|
+
payload.reconciliationReload === true
|
|
1315
|
+
)
|
|
1256
1316
|
}
|
|
1257
1317
|
|
|
1258
1318
|
return (
|
|
@@ -1358,6 +1418,12 @@ class PersistedCollectionRuntime<
|
|
|
1358
1418
|
private latestTerm = 0
|
|
1359
1419
|
private latestSeq = 0
|
|
1360
1420
|
private latestRowVersion = 0
|
|
1421
|
+
// A metadata-only resume snapshot can advance latestRowVersion without
|
|
1422
|
+
// publishing rows. Gap checks must use the last publicly applied version.
|
|
1423
|
+
private publicRowVersion = 0
|
|
1424
|
+
// A reset does not label later tx notifications with their reset epoch.
|
|
1425
|
+
// Old-term payloads must be checked against durable rows even after a new term.
|
|
1426
|
+
private resetTermFence: number | undefined
|
|
1361
1427
|
private localTerm = 1
|
|
1362
1428
|
private localSeq = 0
|
|
1363
1429
|
private localRowVersion = 0
|
|
@@ -2283,6 +2349,8 @@ class PersistedCollectionRuntime<
|
|
|
2283
2349
|
this.lifecycleGeneration++
|
|
2284
2350
|
this.persistedReadiness?.set({ status: `loading` })
|
|
2285
2351
|
this.startupSettled = false
|
|
2352
|
+
this.publicRowVersion = 0
|
|
2353
|
+
this.resetTermFence = undefined
|
|
2286
2354
|
this.hydratedDemands.clear()
|
|
2287
2355
|
this.startupMetadataPromise = null
|
|
2288
2356
|
this.startPromise = null
|
|
@@ -2353,6 +2421,7 @@ class PersistedCollectionRuntime<
|
|
|
2353
2421
|
adapter: HydrationPersistenceAdapter,
|
|
2354
2422
|
): Promise<void> {
|
|
2355
2423
|
let rowsLoaded = false
|
|
2424
|
+
let boundRowVersion: number | undefined
|
|
2356
2425
|
let replayFailure: { reason: unknown } | undefined
|
|
2357
2426
|
const resetSequence = this.resetSequence
|
|
2358
2427
|
this.hydrationSequence++
|
|
@@ -2376,6 +2445,7 @@ class PersistedCollectionRuntime<
|
|
|
2376
2445
|
if (config.lifecycleGeneration !== this.lifecycleGeneration) return
|
|
2377
2446
|
if (resetSequence !== this.resetSequence) return
|
|
2378
2447
|
this.bindResumeSnapshotEvidence(snapshot)
|
|
2448
|
+
boundRowVersion = snapshot.latestRowVersion
|
|
2379
2449
|
} else {
|
|
2380
2450
|
rows = await this.loadSubsetRowsUnsafe(options, adapter)
|
|
2381
2451
|
}
|
|
@@ -2392,6 +2462,12 @@ class PersistedCollectionRuntime<
|
|
|
2392
2462
|
}
|
|
2393
2463
|
const applied = this.applyRowsToCollection(rows)
|
|
2394
2464
|
await whenSyncAccepted(applied)
|
|
2465
|
+
if (boundRowVersion !== undefined) {
|
|
2466
|
+
this.publicRowVersion = Math.max(
|
|
2467
|
+
this.publicRowVersion,
|
|
2468
|
+
boundRowVersion,
|
|
2469
|
+
)
|
|
2470
|
+
}
|
|
2395
2471
|
}
|
|
2396
2472
|
} finally {
|
|
2397
2473
|
if (
|
|
@@ -2620,10 +2696,14 @@ class PersistedCollectionRuntime<
|
|
|
2620
2696
|
const expected = this.persistedResumeGeneration
|
|
2621
2697
|
return (
|
|
2622
2698
|
expected !== undefined &&
|
|
2623
|
-
expected.latestTerm === generation.latestTerm &&
|
|
2624
|
-
expected.latestSeq === generation.latestSeq &&
|
|
2625
2699
|
expected.latestRowVersion === generation.latestRowVersion &&
|
|
2626
|
-
expected.resetEpoch === generation.resetEpoch
|
|
2700
|
+
expected.resetEpoch === generation.resetEpoch &&
|
|
2701
|
+
((expected.latestTerm === generation.latestTerm &&
|
|
2702
|
+
expected.latestSeq === generation.latestSeq) ||
|
|
2703
|
+
// A no-write election reserves a new term at sequence zero. It does
|
|
2704
|
+
// not change the persisted rows or the source resume cursor.
|
|
2705
|
+
(generation.latestTerm > expected.latestTerm &&
|
|
2706
|
+
generation.latestSeq === 0))
|
|
2627
2707
|
)
|
|
2628
2708
|
}
|
|
2629
2709
|
|
|
@@ -2706,6 +2786,37 @@ class PersistedCollectionRuntime<
|
|
|
2706
2786
|
})
|
|
2707
2787
|
}
|
|
2708
2788
|
|
|
2789
|
+
private matchesCollectionSnapshot(
|
|
2790
|
+
rows: Map<TKey, { value: T; metadata?: unknown }>,
|
|
2791
|
+
collectionMetadata: Array<{ key: string; value: unknown }>,
|
|
2792
|
+
): boolean {
|
|
2793
|
+
if (!this.collection || !this.syncControls.metadata) return false
|
|
2794
|
+
const base = this.collection.base
|
|
2795
|
+
if (base.size !== rows.size) return false
|
|
2796
|
+
for (const [key, row] of rows) {
|
|
2797
|
+
if (
|
|
2798
|
+
!base.has(key) ||
|
|
2799
|
+
!equalPersistedSnapshotValues(base.get(key), row.value) ||
|
|
2800
|
+
!equalPersistedSnapshotValues(
|
|
2801
|
+
this.syncControls.metadata.row.get(key),
|
|
2802
|
+
row.metadata,
|
|
2803
|
+
)
|
|
2804
|
+
) {
|
|
2805
|
+
return false
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
const currentMetadata = this.syncControls.metadata.collection.list()
|
|
2809
|
+
if (currentMetadata.length !== collectionMetadata.length) return false
|
|
2810
|
+
const expectedMetadata = new Map(
|
|
2811
|
+
collectionMetadata.map(({ key, value }) => [key, value]),
|
|
2812
|
+
)
|
|
2813
|
+
return currentMetadata.every(
|
|
2814
|
+
({ key, value }) =>
|
|
2815
|
+
expectedMetadata.has(key) &&
|
|
2816
|
+
equalPersistedSnapshotValues(expectedMetadata.get(key), value),
|
|
2817
|
+
)
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2709
2820
|
private async flushQueuedHydrationTransactionsUnsafe(
|
|
2710
2821
|
adapter: HydrationPersistenceAdapter,
|
|
2711
2822
|
): Promise<{ reason: unknown } | undefined> {
|
|
@@ -2834,16 +2945,59 @@ class PersistedCollectionRuntime<
|
|
|
2834
2945
|
return
|
|
2835
2946
|
}
|
|
2836
2947
|
|
|
2948
|
+
const anchor = this.persistedResumeGeneration && {
|
|
2949
|
+
latestRowVersion: this.latestRowVersion,
|
|
2950
|
+
resetEpoch: this.persistedResumeGeneration.resetEpoch,
|
|
2951
|
+
}
|
|
2837
2952
|
const streamPosition = this.nextLocalStreamPosition()
|
|
2838
2953
|
const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2954
|
+
let response: ApplyCommittedTxResponse
|
|
2955
|
+
try {
|
|
2956
|
+
response = await this.persistence.coordinator.requestApplyCommittedTx(
|
|
2957
|
+
this.collectionId,
|
|
2958
|
+
tx,
|
|
2959
|
+
// A hydration-scoped adapter is already inside the shared scheduler.
|
|
2960
|
+
// Ordinary source commits enter it before taking the writer lock.
|
|
2961
|
+
scopedAdapter,
|
|
2962
|
+
)
|
|
2963
|
+
} catch (error) {
|
|
2964
|
+
if (
|
|
2965
|
+
!(error instanceof IndeterminateCommitError) ||
|
|
2966
|
+
!anchor ||
|
|
2967
|
+
!this.persistence.coordinator.reconcileCommittedTx
|
|
2968
|
+
) {
|
|
2969
|
+
throw error
|
|
2970
|
+
}
|
|
2971
|
+
let reconciled: ReconcileCommittedTxResponse
|
|
2972
|
+
try {
|
|
2973
|
+
reconciled = await this.persistence.coordinator.reconcileCommittedTx(
|
|
2974
|
+
this.collectionId,
|
|
2975
|
+
tx,
|
|
2976
|
+
anchor,
|
|
2977
|
+
scopedAdapter,
|
|
2978
|
+
)
|
|
2979
|
+
} catch (reconcileError) {
|
|
2980
|
+
if (reconcileError instanceof PersistedCollectionDurabilityError) {
|
|
2981
|
+
throw reconcileError
|
|
2982
|
+
}
|
|
2983
|
+
throw error
|
|
2984
|
+
}
|
|
2985
|
+
if (!reconciled.ok) {
|
|
2986
|
+
if (reconciled.code === `PERSISTENCE_ERROR`) {
|
|
2987
|
+
throw new PersistedCollectionDurabilityError(
|
|
2988
|
+
`Failed to reconcile collection "${this.collectionId}": ${reconciled.error}`,
|
|
2989
|
+
{
|
|
2990
|
+
cause: reconciled,
|
|
2991
|
+
code: reconciled.sourceCode ?? reconciled.code,
|
|
2992
|
+
path: reconciled.path,
|
|
2993
|
+
},
|
|
2994
|
+
)
|
|
2995
|
+
}
|
|
2996
|
+
throw error
|
|
2997
|
+
}
|
|
2998
|
+
this.recordDurableSourceCommit(transaction, reconciled.committed)
|
|
2999
|
+
return
|
|
3000
|
+
}
|
|
2847
3001
|
if (!response.ok) {
|
|
2848
3002
|
if (response.code === `PERSISTENCE_ERROR`) {
|
|
2849
3003
|
const error = new PersistedCollectionDurabilityError(
|
|
@@ -2860,12 +3014,22 @@ class PersistedCollectionRuntime<
|
|
|
2860
3014
|
`failed to apply external sync transaction through coordinator: ${response.error}`,
|
|
2861
3015
|
)
|
|
2862
3016
|
}
|
|
3017
|
+
this.recordDurableSourceCommit(transaction, {
|
|
3018
|
+
term: response.term,
|
|
3019
|
+
seq: response.seq,
|
|
3020
|
+
rowVersion: response.latestRowVersion,
|
|
3021
|
+
})
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
private recordDurableSourceCommit(
|
|
3025
|
+
transaction: BufferedSyncTransaction<T, TKey>,
|
|
3026
|
+
position: { term: number; seq: number; rowVersion: number },
|
|
3027
|
+
): void {
|
|
2863
3028
|
if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return
|
|
2864
|
-
this.observeStreamPosition(
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
)
|
|
3029
|
+
this.observeStreamPosition(position.term, position.seq, position.rowVersion)
|
|
3030
|
+
if (position.rowVersion === this.publicRowVersion + 1) {
|
|
3031
|
+
this.publicRowVersion = position.rowVersion
|
|
3032
|
+
}
|
|
2869
3033
|
if (
|
|
2870
3034
|
transaction.expectedResumeGenerationOwner ===
|
|
2871
3035
|
this.resumeGenerationOwner &&
|
|
@@ -2873,9 +3037,9 @@ class PersistedCollectionRuntime<
|
|
|
2873
3037
|
) {
|
|
2874
3038
|
this.persistedResumeGeneration = {
|
|
2875
3039
|
...this.persistedResumeGeneration,
|
|
2876
|
-
latestTerm:
|
|
2877
|
-
latestSeq:
|
|
2878
|
-
latestRowVersion:
|
|
3040
|
+
latestTerm: position.term,
|
|
3041
|
+
latestSeq: position.seq,
|
|
3042
|
+
latestRowVersion: position.rowVersion,
|
|
2879
3043
|
}
|
|
2880
3044
|
}
|
|
2881
3045
|
}
|
|
@@ -3402,9 +3566,19 @@ class PersistedCollectionRuntime<
|
|
|
3402
3566
|
if (isCollectionResetPayload(payload)) {
|
|
3403
3567
|
void this.applyMutex
|
|
3404
3568
|
.run(async () => {
|
|
3405
|
-
await this.runInHydrationScope((adapter) =>
|
|
3406
|
-
this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
|
|
3407
|
-
|
|
3569
|
+
await this.runInHydrationScope(async (adapter) => {
|
|
3570
|
+
await this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
|
|
3571
|
+
await this.rebaseDurableStreamFromSnapshotUnsafe(
|
|
3572
|
+
adapter,
|
|
3573
|
+
lifecycleGeneration,
|
|
3574
|
+
)
|
|
3575
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
3576
|
+
// Reloaded rows and the later position-only read are separate
|
|
3577
|
+
// cuts. Zero is the conservative public version in the new epoch.
|
|
3578
|
+
this.publicRowVersion = 0
|
|
3579
|
+
this.resetTermFence = this.latestTerm
|
|
3580
|
+
}
|
|
3581
|
+
})
|
|
3408
3582
|
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
3409
3583
|
await this.flushQueuedTxCommittedUnsafe()
|
|
3410
3584
|
}
|
|
@@ -3433,34 +3607,58 @@ class PersistedCollectionRuntime<
|
|
|
3433
3607
|
lifecycleGeneration = this.lifecycleGeneration,
|
|
3434
3608
|
): Promise<void> {
|
|
3435
3609
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3436
|
-
if (
|
|
3437
|
-
|
|
3610
|
+
if (this.resetTermFence !== undefined) {
|
|
3611
|
+
if (txCommitted.term <= this.resetTermFence) {
|
|
3612
|
+
// This payload could have been committed before the schema reset.
|
|
3613
|
+
// The current durable snapshot is the only safe row authority.
|
|
3614
|
+
await this.runInHydrationScope((adapter) =>
|
|
3615
|
+
this.reloadActiveSubsetsUnsafe(adapter),
|
|
3616
|
+
)
|
|
3617
|
+
return
|
|
3618
|
+
}
|
|
3438
3619
|
}
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3620
|
+
// An exact-ID reconciliation can discover a commit whose original
|
|
3621
|
+
// notification was lost. A peer may already be at this stream position
|
|
3622
|
+
// without having loaded that commit's rows.
|
|
3623
|
+
if (txCommitted.requiresFullReload && txCommitted.reconciliationReload) {
|
|
3624
|
+
await this.invalidateFromCommittedTxUnsafe(
|
|
3625
|
+
txCommitted,
|
|
3626
|
+
this.persistence.adapter,
|
|
3627
|
+
)
|
|
3628
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
3629
|
+
this.observeStreamPosition(
|
|
3630
|
+
txCommitted.term,
|
|
3631
|
+
txCommitted.seq,
|
|
3632
|
+
txCommitted.latestRowVersion,
|
|
3633
|
+
)
|
|
3634
|
+
this.publicRowVersion = Math.max(
|
|
3635
|
+
this.publicRowVersion,
|
|
3636
|
+
txCommitted.latestRowVersion,
|
|
3637
|
+
)
|
|
3638
|
+
await this.flushQueuedTxCommittedUnsafe()
|
|
3639
|
+
}
|
|
3444
3640
|
return
|
|
3445
3641
|
}
|
|
3446
|
-
|
|
3642
|
+
if (txCommitted.latestRowVersion <= this.publicRowVersion) return
|
|
3643
|
+
const observedButUnpublished =
|
|
3644
|
+
txCommitted.term < this.latestTerm ||
|
|
3645
|
+
(txCommitted.term === this.latestTerm &&
|
|
3646
|
+
txCommitted.seq <= this.latestSeq)
|
|
3447
3647
|
const hasGapInCurrentTerm =
|
|
3448
3648
|
txCommitted.term === this.latestTerm &&
|
|
3449
3649
|
txCommitted.seq > this.latestSeq + 1
|
|
3450
3650
|
const hasGapAcrossTerms =
|
|
3451
3651
|
txCommitted.term > this.latestTerm && txCommitted.seq > 1
|
|
3452
|
-
const hasGap =
|
|
3652
|
+
const hasGap =
|
|
3653
|
+
observedButUnpublished ||
|
|
3654
|
+
txCommitted.latestRowVersion > this.publicRowVersion + 1 ||
|
|
3655
|
+
hasGapInCurrentTerm ||
|
|
3656
|
+
hasGapAcrossTerms
|
|
3453
3657
|
|
|
3454
3658
|
if (hasGap) {
|
|
3455
3659
|
await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
|
|
3456
3660
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3457
|
-
if (
|
|
3458
|
-
txCommitted.term < this.latestTerm ||
|
|
3459
|
-
(txCommitted.term === this.latestTerm &&
|
|
3460
|
-
txCommitted.seq <= this.latestSeq)
|
|
3461
|
-
) {
|
|
3462
|
-
return
|
|
3463
|
-
}
|
|
3661
|
+
if (txCommitted.latestRowVersion <= this.publicRowVersion) return
|
|
3464
3662
|
}
|
|
3465
3663
|
|
|
3466
3664
|
this.observeStreamPosition(
|
|
@@ -3474,10 +3672,34 @@ class PersistedCollectionRuntime<
|
|
|
3474
3672
|
this.persistence.adapter,
|
|
3475
3673
|
)
|
|
3476
3674
|
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
3675
|
+
this.publicRowVersion = Math.max(
|
|
3676
|
+
this.publicRowVersion,
|
|
3677
|
+
txCommitted.latestRowVersion,
|
|
3678
|
+
)
|
|
3477
3679
|
await this.flushQueuedTxCommittedUnsafe()
|
|
3478
3680
|
}
|
|
3479
3681
|
}
|
|
3480
3682
|
|
|
3683
|
+
private async rebaseDurableStreamFromSnapshotUnsafe(
|
|
3684
|
+
adapter: HydrationPersistenceAdapter,
|
|
3685
|
+
lifecycleGeneration: number,
|
|
3686
|
+
): Promise<void> {
|
|
3687
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3688
|
+
const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
|
|
3689
|
+
includeRows: false,
|
|
3690
|
+
})
|
|
3691
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3692
|
+
// The reset epoch can restart row versions at zero. Keep the term/seq
|
|
3693
|
+
// watermark to fence old notifications, but rebase version counters.
|
|
3694
|
+
this.latestRowVersion = snapshot.latestRowVersion
|
|
3695
|
+
this.localRowVersion = snapshot.latestRowVersion
|
|
3696
|
+
this.observeStreamPosition(
|
|
3697
|
+
snapshot.latestTerm,
|
|
3698
|
+
snapshot.latestSeq,
|
|
3699
|
+
snapshot.latestRowVersion,
|
|
3700
|
+
)
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3481
3703
|
private async recoverFromSeqGapUnsafe(
|
|
3482
3704
|
lifecycleGeneration: number,
|
|
3483
3705
|
): Promise<void> {
|
|
@@ -3487,7 +3709,7 @@ class PersistedCollectionRuntime<
|
|
|
3487
3709
|
try {
|
|
3488
3710
|
pullResponse = await this.persistence.coordinator.pullSince(
|
|
3489
3711
|
this.collectionId,
|
|
3490
|
-
this.
|
|
3712
|
+
this.publicRowVersion,
|
|
3491
3713
|
)
|
|
3492
3714
|
} catch (error) {
|
|
3493
3715
|
console.warn(`Failed pullSince recovery attempt:`, error)
|
|
@@ -3506,6 +3728,10 @@ class PersistedCollectionRuntime<
|
|
|
3506
3728
|
await this.runInHydrationScope((adapter) =>
|
|
3507
3729
|
this.reloadActiveSubsetsUnsafe(adapter),
|
|
3508
3730
|
)
|
|
3731
|
+
this.publicRowVersion = Math.max(
|
|
3732
|
+
this.publicRowVersion,
|
|
3733
|
+
pullResponse.latestRowVersion,
|
|
3734
|
+
)
|
|
3509
3735
|
return
|
|
3510
3736
|
}
|
|
3511
3737
|
const deltas = pullResponse.deltas
|
|
@@ -3513,6 +3739,10 @@ class PersistedCollectionRuntime<
|
|
|
3513
3739
|
await this.runInHydrationScope((adapter) =>
|
|
3514
3740
|
this.reloadActiveSubsetsUnsafe(adapter),
|
|
3515
3741
|
)
|
|
3742
|
+
this.publicRowVersion = Math.max(
|
|
3743
|
+
this.publicRowVersion,
|
|
3744
|
+
pullResponse.latestRowVersion,
|
|
3745
|
+
)
|
|
3516
3746
|
return
|
|
3517
3747
|
}
|
|
3518
3748
|
|
|
@@ -3538,14 +3768,24 @@ class PersistedCollectionRuntime<
|
|
|
3538
3768
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3539
3769
|
}
|
|
3540
3770
|
})
|
|
3771
|
+
this.publicRowVersion = Math.max(
|
|
3772
|
+
this.publicRowVersion,
|
|
3773
|
+
pullResponse.latestRowVersion,
|
|
3774
|
+
)
|
|
3541
3775
|
return
|
|
3542
3776
|
}
|
|
3543
3777
|
}
|
|
3544
3778
|
}
|
|
3545
3779
|
|
|
3546
3780
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3781
|
+
// A gap is not a schema reset. Replace the durable snapshot in one
|
|
3782
|
+
// publication so readers never see an empty intermediate Collection.
|
|
3547
3783
|
await this.runInHydrationScope((adapter) =>
|
|
3548
|
-
this.
|
|
3784
|
+
this.reloadActiveSubsetsUnsafe(adapter),
|
|
3785
|
+
)
|
|
3786
|
+
this.publicRowVersion = Math.max(
|
|
3787
|
+
this.publicRowVersion,
|
|
3788
|
+
this.latestRowVersion,
|
|
3549
3789
|
)
|
|
3550
3790
|
|
|
3551
3791
|
if (this.mode === `sync-present`) {
|
|
@@ -3580,14 +3820,15 @@ class PersistedCollectionRuntime<
|
|
|
3580
3820
|
txCommitted: TxCommitted,
|
|
3581
3821
|
adapter: HydrationPersistenceAdapter,
|
|
3582
3822
|
): Promise<void> {
|
|
3583
|
-
const reloadActiveSubsets = () =>
|
|
3823
|
+
const reloadActiveSubsets = (skipUnchangedReplace = false) =>
|
|
3584
3824
|
this.runInHydrationScope(
|
|
3585
|
-
(scopedAdapter) =>
|
|
3825
|
+
(scopedAdapter) =>
|
|
3826
|
+
this.reloadActiveSubsetsUnsafe(scopedAdapter, skipUnchangedReplace),
|
|
3586
3827
|
adapter,
|
|
3587
3828
|
)
|
|
3588
3829
|
|
|
3589
3830
|
if (txCommitted.requiresFullReload) {
|
|
3590
|
-
await reloadActiveSubsets()
|
|
3831
|
+
await reloadActiveSubsets(txCommitted.reconciliationReload === true)
|
|
3591
3832
|
return
|
|
3592
3833
|
}
|
|
3593
3834
|
|
|
@@ -3671,6 +3912,7 @@ class PersistedCollectionRuntime<
|
|
|
3671
3912
|
|
|
3672
3913
|
private async reloadActiveSubsetsUnsafe(
|
|
3673
3914
|
adapter: HydrationPersistenceAdapter,
|
|
3915
|
+
skipUnchangedReplace = false,
|
|
3674
3916
|
): Promise<void> {
|
|
3675
3917
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
3676
3918
|
const truncateGeneration = this.sourceTruncateGeneration
|
|
@@ -3704,15 +3946,35 @@ class PersistedCollectionRuntime<
|
|
|
3704
3946
|
hydrationContext.suppliedRowKeys.add(key)
|
|
3705
3947
|
}
|
|
3706
3948
|
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3949
|
+
if (
|
|
3950
|
+
skipUnchangedReplace &&
|
|
3951
|
+
this.matchesCollectionSnapshot(mergedRows, collectionMetadata)
|
|
3952
|
+
) {
|
|
3953
|
+
// A full replacement would also clear metadata for missing rows.
|
|
3954
|
+
// Clear only those entries so equal public rows do not republish.
|
|
3955
|
+
const orphanKeys =
|
|
3956
|
+
this.collection?._syncedRowMetadataKeysOutsideBase() ?? []
|
|
3957
|
+
if (orphanKeys.length > 0) {
|
|
3958
|
+
const applied = this.withInternalApply(() => {
|
|
3959
|
+
this.syncControls.begin?.()
|
|
3960
|
+
for (const key of orphanKeys) {
|
|
3961
|
+
this.syncControls.metadata?.row.delete(key)
|
|
3962
|
+
}
|
|
3963
|
+
return this.syncControls.commit?.() ?? true
|
|
3964
|
+
})
|
|
3965
|
+
await whenSyncAccepted(applied)
|
|
3966
|
+
}
|
|
3967
|
+
} else {
|
|
3968
|
+
const applied = this.replaceCollectionSnapshot(
|
|
3969
|
+
Array.from(mergedRows.entries()).map(([key, row]) => ({
|
|
3970
|
+
key,
|
|
3971
|
+
value: row.value,
|
|
3972
|
+
metadata: row.metadata,
|
|
3973
|
+
})),
|
|
3974
|
+
collectionMetadata,
|
|
3975
|
+
)
|
|
3976
|
+
await whenSyncAccepted(applied)
|
|
3977
|
+
}
|
|
3716
3978
|
} finally {
|
|
3717
3979
|
if (
|
|
3718
3980
|
this.activeHydrationContext === hydrationContext &&
|