@tanstack/db-sqlite-persistence-core 0.4.8 → 0.4.9
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 +10 -1
- package/dist/cjs/broadcast-coordinator.cjs +117 -29
- package/dist/cjs/broadcast-coordinator.cjs.map +1 -1
- package/dist/cjs/broadcast-coordinator.d.cts +16 -8
- package/dist/cjs/index.cjs +4 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/persisted.cjs +1408 -278
- package/dist/cjs/persisted.cjs.map +1 -1
- package/dist/cjs/persisted.d.cts +62 -15
- package/dist/cjs/remote-subset-wire.cjs +11 -0
- package/dist/cjs/remote-subset-wire.cjs.map +1 -1
- package/dist/cjs/remote-subset-wire.d.cts +1 -0
- package/dist/cjs/sqlite-core-adapter.cjs +623 -73
- package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
- package/dist/cjs/sqlite-core-adapter.d.cts +46 -12
- package/dist/esm/broadcast-coordinator.d.ts +16 -8
- package/dist/esm/broadcast-coordinator.js +118 -30
- package/dist/esm/broadcast-coordinator.js.map +1 -1
- package/dist/esm/index.js +6 -2
- package/dist/esm/persisted.d.ts +62 -15
- package/dist/esm/persisted.js +1409 -279
- package/dist/esm/persisted.js.map +1 -1
- package/dist/esm/remote-subset-wire.d.ts +1 -0
- package/dist/esm/remote-subset-wire.js +11 -0
- package/dist/esm/remote-subset-wire.js.map +1 -1
- package/dist/esm/sqlite-core-adapter.d.ts +46 -12
- package/dist/esm/sqlite-core-adapter.js +626 -76
- package/dist/esm/sqlite-core-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/broadcast-coordinator.ts +153 -23
- package/src/persisted.ts +1973 -322
- package/src/remote-subset-wire.ts +12 -0
- package/src/sqlite-core-adapter.ts +760 -77
package/src/persisted.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
SYNC_PERSISTENCE_PROTOCOL,
|
|
6
6
|
SYNC_PERSISTENCE_VERSION,
|
|
7
7
|
SyncTransactionAbortedError,
|
|
8
|
+
collectionOptionsClaim,
|
|
8
9
|
compileSingleRowExpression,
|
|
9
10
|
equalPersistedSnapshotValues,
|
|
10
11
|
getLoadSubsetDemandKey,
|
|
@@ -354,6 +355,97 @@ export type PersistedRowScanOptions = SyncPersistenceScanOptions
|
|
|
354
355
|
|
|
355
356
|
export type PersistedKeySetEvidence = SyncPersistenceKeySetEvidence
|
|
356
357
|
|
|
358
|
+
export type PersistedCacheGenerationClaim = {
|
|
359
|
+
storageCollectionId: string
|
|
360
|
+
claimId: string
|
|
361
|
+
/** Present when the adapter expires dormant claims. */
|
|
362
|
+
expiresAtMs?: number
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** An eager Collection name or a claim-bound persisted cache generation. */
|
|
366
|
+
export type PersistedStorageTarget =
|
|
367
|
+
| { kind: `eager`; collectionId: string }
|
|
368
|
+
| {
|
|
369
|
+
kind: `managed`
|
|
370
|
+
storageCollectionId: string
|
|
371
|
+
claimId: string
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function eagerStorageTarget(
|
|
375
|
+
collectionId: string,
|
|
376
|
+
): PersistedStorageTarget {
|
|
377
|
+
return { kind: `eager`, collectionId }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function managedStorageTarget(
|
|
381
|
+
storageCollectionId: string,
|
|
382
|
+
claimId: string,
|
|
383
|
+
): PersistedStorageTarget {
|
|
384
|
+
return { kind: `managed`, storageCollectionId, claimId }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function resolvePersistedStorageTarget(
|
|
388
|
+
target: PersistedStorageTarget,
|
|
389
|
+
legacyClaimId?: string,
|
|
390
|
+
): { collectionId: string; claimId?: string } {
|
|
391
|
+
const input: unknown = target
|
|
392
|
+
if (typeof input !== `object` || input === null) {
|
|
393
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
394
|
+
`SQLite persistence requires an explicit eager or managed storage target`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
const fields = input as Record<string, unknown>
|
|
398
|
+
if (fields.kind === `eager`) {
|
|
399
|
+
if (
|
|
400
|
+
typeof fields.collectionId !== `string` ||
|
|
401
|
+
fields.collectionId.trim().length === 0
|
|
402
|
+
) {
|
|
403
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
404
|
+
`An eager storage target requires a nonempty collectionId`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
if (
|
|
408
|
+
legacyClaimId !== undefined ||
|
|
409
|
+
`claimId` in fields ||
|
|
410
|
+
`storageCollectionId` in fields
|
|
411
|
+
) {
|
|
412
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
413
|
+
`An eager storage target cannot carry a persisted cache claim`,
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
return { collectionId: fields.collectionId }
|
|
417
|
+
}
|
|
418
|
+
if (fields.kind !== `managed`) {
|
|
419
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
420
|
+
`Unknown SQLite persistence storage target kind`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
typeof fields.storageCollectionId !== `string` ||
|
|
425
|
+
fields.storageCollectionId.trim().length === 0 ||
|
|
426
|
+
typeof fields.claimId !== `string` ||
|
|
427
|
+
fields.claimId.trim().length === 0
|
|
428
|
+
) {
|
|
429
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
430
|
+
`A managed storage target requires a storageCollectionId and persisted cache claim`,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
if (`collectionId` in fields) {
|
|
434
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
435
|
+
`A managed storage target cannot carry an eager collectionId`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
if (legacyClaimId !== undefined && legacyClaimId !== fields.claimId) {
|
|
439
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
440
|
+
`The persisted cache claim does not match its storage target`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
collectionId: fields.storageCollectionId,
|
|
445
|
+
claimId: fields.claimId,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
357
449
|
type PersistedResumeGeneration = {
|
|
358
450
|
latestTerm: number
|
|
359
451
|
latestSeq: number
|
|
@@ -384,6 +476,8 @@ export type PersistedTx<
|
|
|
384
476
|
term: number
|
|
385
477
|
seq: number
|
|
386
478
|
rowVersion: number
|
|
479
|
+
/** Claim authorizing this transaction's persisted cache generation. */
|
|
480
|
+
cacheGenerationClaimId?: string
|
|
387
481
|
truncate?: boolean
|
|
388
482
|
mutations: Array<
|
|
389
483
|
| {
|
|
@@ -423,9 +517,12 @@ export const SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY = Symbol.for(
|
|
|
423
517
|
|
|
424
518
|
export interface PersistenceAdapter {
|
|
425
519
|
loadSubset: (
|
|
426
|
-
|
|
520
|
+
target: PersistedStorageTarget,
|
|
427
521
|
options: LoadSubsetOptions,
|
|
428
|
-
ctx?: {
|
|
522
|
+
ctx?: {
|
|
523
|
+
requiredIndexSignatures?: ReadonlyArray<string>
|
|
524
|
+
cacheGenerationClaimId?: string
|
|
525
|
+
},
|
|
429
526
|
) => Promise<
|
|
430
527
|
Array<{
|
|
431
528
|
key: string | number
|
|
@@ -434,10 +531,11 @@ export interface PersistenceAdapter {
|
|
|
434
531
|
}>
|
|
435
532
|
>
|
|
436
533
|
loadResumeSnapshot: (
|
|
437
|
-
|
|
534
|
+
target: PersistedStorageTarget,
|
|
438
535
|
ctx?: {
|
|
439
536
|
requiredIndexSignatures?: ReadonlyArray<string>
|
|
440
537
|
includeRows?: boolean
|
|
538
|
+
cacheGenerationClaimId?: string
|
|
441
539
|
},
|
|
442
540
|
) => Promise<{
|
|
443
541
|
rows: Array<{
|
|
@@ -452,35 +550,64 @@ export interface PersistenceAdapter {
|
|
|
452
550
|
latestRowVersion: number
|
|
453
551
|
resetEpoch: number
|
|
454
552
|
}>
|
|
455
|
-
applyCommittedTx: (
|
|
553
|
+
applyCommittedTx: (
|
|
554
|
+
target: PersistedStorageTarget,
|
|
555
|
+
tx: PersistedTx,
|
|
556
|
+
) => Promise<void>
|
|
557
|
+
claimCacheGeneration?: (
|
|
558
|
+
collectionId: string,
|
|
559
|
+
) => Promise<PersistedCacheGenerationClaim>
|
|
560
|
+
rotateCacheGeneration?: (
|
|
561
|
+
collectionId: string,
|
|
562
|
+
claimId: string,
|
|
563
|
+
resetMetadata?: { key: string; value: unknown },
|
|
564
|
+
expectedStorageCollectionId?: string,
|
|
565
|
+
) => Promise<PersistedCacheGenerationClaim>
|
|
566
|
+
releaseCacheGenerationClaim?: (claimId: string) => Promise<void>
|
|
567
|
+
renewCacheGenerationClaim?: (
|
|
568
|
+
storageCollectionId: string,
|
|
569
|
+
claimId: string,
|
|
570
|
+
) => Promise<number | undefined>
|
|
571
|
+
getCacheGenerationNow?: () => number
|
|
456
572
|
/** Atomically certify or apply one source transaction after a lost response. */
|
|
457
573
|
reconcileCommittedTx?: (
|
|
458
|
-
|
|
574
|
+
target: PersistedStorageTarget,
|
|
459
575
|
tx: PersistedTx,
|
|
460
576
|
anchor: CommittedTxAnchor,
|
|
461
577
|
) => Promise<ReconciledCommittedTx>
|
|
462
578
|
loadCollectionMetadata?: (
|
|
463
|
-
|
|
579
|
+
target: PersistedStorageTarget,
|
|
580
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
464
581
|
) => Promise<Array<{ key: string; value: unknown }>>
|
|
465
582
|
scanRows?: (
|
|
466
|
-
|
|
583
|
+
target: PersistedStorageTarget,
|
|
467
584
|
options?: PersistedRowScanOptions,
|
|
585
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
468
586
|
) => Promise<Array<PersistedScannedRow>>
|
|
469
587
|
ensureIndex: (
|
|
470
|
-
|
|
588
|
+
target: PersistedStorageTarget,
|
|
471
589
|
signature: string,
|
|
472
590
|
spec: PersistedIndexSpec,
|
|
591
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
473
592
|
) => Promise<void>
|
|
474
|
-
markIndexRemoved?: (
|
|
475
|
-
|
|
593
|
+
markIndexRemoved?: (
|
|
594
|
+
target: PersistedStorageTarget,
|
|
595
|
+
signature: string,
|
|
596
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
597
|
+
) => Promise<void>
|
|
598
|
+
getStreamPosition?: (
|
|
599
|
+
target: PersistedStorageTarget,
|
|
600
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
601
|
+
) => Promise<{
|
|
476
602
|
latestTerm: number
|
|
477
603
|
latestSeq: number
|
|
478
604
|
latestRowVersion: number
|
|
479
605
|
}>
|
|
480
606
|
/** Reserve a new durable election term before announcing Browser leadership. */
|
|
481
607
|
reserveLeadershipTerm?: (
|
|
482
|
-
|
|
608
|
+
target: PersistedStorageTarget,
|
|
483
609
|
observedTerm: number,
|
|
610
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
484
611
|
) => Promise<{
|
|
485
612
|
latestTerm: number
|
|
486
613
|
latestSeq: number
|
|
@@ -504,8 +631,9 @@ export interface PersistenceAdapter {
|
|
|
504
631
|
|
|
505
632
|
export type HydrationPersistenceAdapter = PersistenceAdapter & {
|
|
506
633
|
pullSince?: (
|
|
507
|
-
|
|
634
|
+
target: PersistedStorageTarget,
|
|
508
635
|
fromRowVersion: number,
|
|
636
|
+
ctx?: { cacheGenerationClaimId?: string },
|
|
509
637
|
) => Promise<PersistencePullSinceResult>
|
|
510
638
|
}
|
|
511
639
|
|
|
@@ -558,6 +686,7 @@ export interface PersistedCollectionCoordinator {
|
|
|
558
686
|
setAdapterForCollection?: (
|
|
559
687
|
collectionId: string,
|
|
560
688
|
adapter: PersistenceAdapter,
|
|
689
|
+
cacheGenerationClaimId?: string,
|
|
561
690
|
) => void
|
|
562
691
|
subscribe: (
|
|
563
692
|
collectionId: string,
|
|
@@ -589,10 +718,12 @@ export interface PersistedCollectionCoordinator {
|
|
|
589
718
|
spec: PersistedIndexSpec,
|
|
590
719
|
scopedAdapter?: HydrationPersistenceAdapter,
|
|
591
720
|
localEnsureCompleted?: boolean,
|
|
721
|
+
cacheGenerationClaimId?: string,
|
|
592
722
|
) => Promise<void>
|
|
593
723
|
requestApplyLocalMutations?: (
|
|
594
724
|
collectionId: string,
|
|
595
725
|
mutations: Array<PersistedMutationEnvelope>,
|
|
726
|
+
cacheGenerationClaimId?: string,
|
|
596
727
|
) => Promise<ApplyLocalMutationsResponse>
|
|
597
728
|
/**
|
|
598
729
|
* Applies a committed transaction through the supplied adapter when present.
|
|
@@ -617,6 +748,7 @@ export interface PersistedCollectionCoordinator {
|
|
|
617
748
|
collectionId: string,
|
|
618
749
|
fromRowVersion: number,
|
|
619
750
|
scopedAdapter?: HydrationPersistenceAdapter,
|
|
751
|
+
cacheGenerationClaimId?: string,
|
|
620
752
|
) => Promise<PullSinceResponse>
|
|
621
753
|
}
|
|
622
754
|
|
|
@@ -694,6 +826,7 @@ export type PersistedInitialRenderOptions = {
|
|
|
694
826
|
}
|
|
695
827
|
|
|
696
828
|
const DEFAULT_NETWORK_TIMEOUT_MS = 3_000
|
|
829
|
+
const CACHE_CLAIM_RENEW_WINDOW_MS = 60_000
|
|
697
830
|
const MAX_TIMER_TIMEOUT_MS = 2_147_483_647
|
|
698
831
|
|
|
699
832
|
function getNetworkTimeoutMs(
|
|
@@ -813,7 +946,7 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
|
|
|
813
946
|
) => void)
|
|
814
947
|
| null
|
|
815
948
|
commit: ((signal?: AbortSignal) => SyncAppliedReceipt) | null
|
|
816
|
-
truncate: (() => void) | null
|
|
949
|
+
truncate: ((options?: { markReady?: boolean }) => void) | null
|
|
817
950
|
metadata: SyncMetadataApi<TKey> | null
|
|
818
951
|
markError: ((error: unknown) => void) | null
|
|
819
952
|
}
|
|
@@ -905,11 +1038,8 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
|
|
|
905
1038
|
if (acquisitions!.size === 0) {
|
|
906
1039
|
this.remoteSubsetAcquisitions.delete(collectionId)
|
|
907
1040
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
} catch {
|
|
911
|
-
// Calling the owner transferred the lease even when its load rejected.
|
|
912
|
-
}
|
|
1041
|
+
// The owner may need unload to settle its load. The call itself already
|
|
1042
|
+
// transferred the lease, so waiting for load here can deadlock recovery.
|
|
913
1043
|
await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options)
|
|
914
1044
|
}
|
|
915
1045
|
|
|
@@ -927,14 +1057,9 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
|
|
|
927
1057
|
this.remoteSubsetAcquisitions.delete(collectionId)
|
|
928
1058
|
this.remoteSubsetOwners.delete(collectionId)
|
|
929
1059
|
for (const acquisition of acquisitions?.values() ?? []) {
|
|
930
|
-
void (
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
} catch {
|
|
934
|
-
// Calling the owner transferred the lease even when its load rejected.
|
|
935
|
-
}
|
|
936
|
-
await unloadRemoteSubsetOwner(owner, acquisition.options)
|
|
937
|
-
})().catch(() => undefined)
|
|
1060
|
+
void unloadRemoteSubsetOwner(owner, acquisition.options).catch(
|
|
1061
|
+
() => undefined,
|
|
1062
|
+
)
|
|
938
1063
|
}
|
|
939
1064
|
}
|
|
940
1065
|
}
|
|
@@ -961,7 +1086,12 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
|
|
|
961
1086
|
}
|
|
962
1087
|
|
|
963
1088
|
try {
|
|
964
|
-
await adapter.applyCommittedTx(
|
|
1089
|
+
await adapter.applyCommittedTx(
|
|
1090
|
+
tx.cacheGenerationClaimId
|
|
1091
|
+
? managedStorageTarget(collectionId, tx.cacheGenerationClaimId)
|
|
1092
|
+
: eagerStorageTarget(collectionId),
|
|
1093
|
+
tx,
|
|
1094
|
+
)
|
|
965
1095
|
} catch (error) {
|
|
966
1096
|
throw toPersistedCollectionDurabilityError(collectionId, error)
|
|
967
1097
|
}
|
|
@@ -1112,6 +1242,7 @@ type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
|
|
|
1112
1242
|
truncate: boolean
|
|
1113
1243
|
internal: boolean
|
|
1114
1244
|
lifecycleGeneration: number
|
|
1245
|
+
admittedStorageCollectionId?: string
|
|
1115
1246
|
expectedResumeGenerationOwner?: symbol
|
|
1116
1247
|
signal?: AbortSignal
|
|
1117
1248
|
prependHydrationRows: (
|
|
@@ -1199,6 +1330,21 @@ class ApplyMutex {
|
|
|
1199
1330
|
}
|
|
1200
1331
|
}
|
|
1201
1332
|
|
|
1333
|
+
function matchesAnySubset(
|
|
1334
|
+
value: object,
|
|
1335
|
+
evaluators: ReadonlyArray<ReturnType<
|
|
1336
|
+
typeof compileSingleRowExpression
|
|
1337
|
+
> | null>,
|
|
1338
|
+
): boolean {
|
|
1339
|
+
return evaluators.some(
|
|
1340
|
+
(evaluator) =>
|
|
1341
|
+
!evaluator ||
|
|
1342
|
+
toBooleanPredicate(
|
|
1343
|
+
evaluator(value as Record<string, unknown>) as boolean | null,
|
|
1344
|
+
),
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1202
1348
|
function toStableSerializable(value: unknown): unknown {
|
|
1203
1349
|
if (value == null) {
|
|
1204
1350
|
return value
|
|
@@ -1369,18 +1515,36 @@ class PersistedCollectionRuntime<
|
|
|
1369
1515
|
string,
|
|
1370
1516
|
LoadSubsetOptions
|
|
1371
1517
|
>()
|
|
1518
|
+
private readonly scopedRefreshes = new Map<LoadSubsetOptions, string>()
|
|
1519
|
+
/** A retained demand may use fresh physical request data after rotation. */
|
|
1520
|
+
private readonly rotatedAcquisitions = new Map<
|
|
1521
|
+
LoadSubsetOptions,
|
|
1522
|
+
LoadSubsetOptions
|
|
1523
|
+
>()
|
|
1524
|
+
/** An in-flight load waits for the recovery acquisition of its demand. */
|
|
1525
|
+
private readonly rotatedEnsures = new WeakMap<
|
|
1526
|
+
LoadSubsetOptions,
|
|
1527
|
+
Promise<void>
|
|
1528
|
+
>()
|
|
1372
1529
|
private readonly queuedHydrationTransactions: Array<
|
|
1373
1530
|
BufferedSyncTransaction<T, TKey>
|
|
1374
1531
|
> = []
|
|
1375
1532
|
private readonly queuedTxCommitted: Array<{
|
|
1376
1533
|
txCommitted: TxCommitted
|
|
1377
1534
|
lifecycleGeneration: number
|
|
1535
|
+
storageCollectionId: string
|
|
1378
1536
|
}> = []
|
|
1379
1537
|
private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
|
|
1380
1538
|
private readonly hydratedDemands = new Set<string | undefined>()
|
|
1381
1539
|
|
|
1382
1540
|
private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
|
|
1383
1541
|
null
|
|
1542
|
+
// Cleanup ends the active sync run, but cannot transfer this runtime's owner.
|
|
1543
|
+
private boundCollection: Collection<
|
|
1544
|
+
T,
|
|
1545
|
+
TKey,
|
|
1546
|
+
PersistedCollectionUtils
|
|
1547
|
+
> | null = null
|
|
1384
1548
|
private syncControls: SyncControlFns<T, TKey> = {
|
|
1385
1549
|
begin: null,
|
|
1386
1550
|
write: null,
|
|
@@ -1411,9 +1575,22 @@ class PersistedCollectionRuntime<
|
|
|
1411
1575
|
private indexAddedUnsubscribe: (() => void) | null = null
|
|
1412
1576
|
private indexRemovedUnsubscribe: (() => void) | null = null
|
|
1413
1577
|
private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
|
|
1578
|
+
private cacheClaimRenewTimer: ReturnType<typeof setTimeout> | null = null
|
|
1579
|
+
private cacheClaimRecovery: Promise<void> | null = null
|
|
1414
1580
|
private nextRequestId = 0
|
|
1415
1581
|
private startupSettled = false
|
|
1416
1582
|
private sourceTruncateGeneration = 0
|
|
1583
|
+
/** Durable rows cannot replace source rows during scoped recovery. */
|
|
1584
|
+
private scopedRecovery = false
|
|
1585
|
+
private scopedRecoveryPending = false
|
|
1586
|
+
private scopedRecoverySequence = 0
|
|
1587
|
+
private recoverQueuedAfterLocalLoadFailure = false
|
|
1588
|
+
private cacheGenerationClaim: PersistedCacheGenerationClaim | undefined
|
|
1589
|
+
private startupCacheGenerationRotated = false
|
|
1590
|
+
private remoteSubsetOwner: RemoteSubsetOwner | undefined
|
|
1591
|
+
// Undefined before source assignment; null means the source cannot restart.
|
|
1592
|
+
private restartSourceAfterScopedRecovery:
|
|
1593
|
+
((cacheRotated: Promise<void>) => void | Promise<void>) | null | undefined
|
|
1417
1594
|
|
|
1418
1595
|
private latestTerm = 0
|
|
1419
1596
|
private latestSeq = 0
|
|
@@ -1435,8 +1612,234 @@ class PersistedCollectionRuntime<
|
|
|
1435
1612
|
private readonly syncMode: `eager` | `on-demand`,
|
|
1436
1613
|
private readonly dbName: string,
|
|
1437
1614
|
private readonly persistedReadiness?: PersistedReadinessTracker,
|
|
1615
|
+
private readonly optionsClaim?: () => void | (() => void),
|
|
1438
1616
|
) {}
|
|
1439
1617
|
|
|
1618
|
+
private get storageCollectionId(): string {
|
|
1619
|
+
return this.cacheGenerationClaim?.storageCollectionId ?? this.collectionId
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private storageTargetFor(
|
|
1623
|
+
storageCollectionId: string = this.storageCollectionId,
|
|
1624
|
+
claimId: string | undefined = this.cacheGenerationClaim?.claimId,
|
|
1625
|
+
): PersistedStorageTarget {
|
|
1626
|
+
if (
|
|
1627
|
+
this.mode === `sync-present` &&
|
|
1628
|
+
this.syncMode === `on-demand` &&
|
|
1629
|
+
this.persistence.adapter.claimCacheGeneration
|
|
1630
|
+
) {
|
|
1631
|
+
if (!claimId) {
|
|
1632
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
1633
|
+
`A managed on-demand cache operation requires a persisted cache claim`,
|
|
1634
|
+
)
|
|
1635
|
+
}
|
|
1636
|
+
return { kind: `managed`, storageCollectionId, claimId }
|
|
1637
|
+
}
|
|
1638
|
+
return { kind: `eager`, collectionId: storageCollectionId }
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private cacheGenerationNow(): number {
|
|
1642
|
+
return this.persistence.adapter.getCacheGenerationNow?.() ?? Date.now()
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private hasExpiredCacheClaim(): boolean {
|
|
1646
|
+
const expiresAtMs = this.cacheGenerationClaim?.expiresAtMs
|
|
1647
|
+
return expiresAtMs !== undefined && this.cacheGenerationNow() >= expiresAtMs
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
private async claimCacheGeneration(
|
|
1651
|
+
lifecycleGeneration: number,
|
|
1652
|
+
): Promise<void> {
|
|
1653
|
+
if (this.mode !== `sync-present` || this.syncMode !== `on-demand`) return
|
|
1654
|
+
const adapter = this.persistence.adapter
|
|
1655
|
+
const capabilities = [
|
|
1656
|
+
adapter.claimCacheGeneration,
|
|
1657
|
+
adapter.rotateCacheGeneration,
|
|
1658
|
+
adapter.renewCacheGenerationClaim,
|
|
1659
|
+
adapter.releaseCacheGenerationClaim,
|
|
1660
|
+
]
|
|
1661
|
+
if (capabilities.some(Boolean) && !capabilities.every(Boolean)) {
|
|
1662
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
1663
|
+
`An on-demand managed cache generation adapter must implement claimCacheGeneration, rotateCacheGeneration, renewCacheGenerationClaim, and releaseCacheGenerationClaim together`,
|
|
1664
|
+
)
|
|
1665
|
+
}
|
|
1666
|
+
if (!adapter.claimCacheGeneration) return
|
|
1667
|
+
const claim = await adapter.claimCacheGeneration(this.collectionId)
|
|
1668
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
1669
|
+
await adapter.releaseCacheGenerationClaim?.(claim.claimId)
|
|
1670
|
+
return
|
|
1671
|
+
}
|
|
1672
|
+
this.cacheGenerationClaim = claim
|
|
1673
|
+
this.scheduleCacheClaimRenewal(claim)
|
|
1674
|
+
this.attachCoordinatorSubscription()
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
private clearCacheClaimRenewal(): void {
|
|
1678
|
+
if (this.cacheClaimRenewTimer !== null) {
|
|
1679
|
+
clearTimeout(this.cacheClaimRenewTimer)
|
|
1680
|
+
this.cacheClaimRenewTimer = null
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
private scheduleCacheClaimRenewal(
|
|
1685
|
+
claim: PersistedCacheGenerationClaim,
|
|
1686
|
+
): void {
|
|
1687
|
+
this.clearCacheClaimRenewal()
|
|
1688
|
+
if (
|
|
1689
|
+
claim.expiresAtMs === undefined ||
|
|
1690
|
+
!this.persistence.adapter.renewCacheGenerationClaim
|
|
1691
|
+
) {
|
|
1692
|
+
return
|
|
1693
|
+
}
|
|
1694
|
+
const remaining = claim.expiresAtMs - this.cacheGenerationNow()
|
|
1695
|
+
const delay =
|
|
1696
|
+
remaining <= 0
|
|
1697
|
+
? 1_000
|
|
1698
|
+
: Math.max(
|
|
1699
|
+
1,
|
|
1700
|
+
Math.min(CACHE_CLAIM_RENEW_WINDOW_MS, Math.floor(remaining / 2)),
|
|
1701
|
+
)
|
|
1702
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
1703
|
+
this.cacheClaimRenewTimer = setTimeout(() => {
|
|
1704
|
+
this.cacheClaimRenewTimer = null
|
|
1705
|
+
void this.ensureCacheClaimActive().catch(() => {
|
|
1706
|
+
// A failed renewal does not establish claim loss. Adapter reads and
|
|
1707
|
+
// writes still check the claim; retry the background check without
|
|
1708
|
+
// turning one transient storage error into a permanent Collection
|
|
1709
|
+
// failure. An expired claim retries at a bounded pace.
|
|
1710
|
+
if (
|
|
1711
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
1712
|
+
this.cacheGenerationClaim === claim
|
|
1713
|
+
) {
|
|
1714
|
+
this.scheduleCacheClaimRenewal(claim)
|
|
1715
|
+
}
|
|
1716
|
+
})
|
|
1717
|
+
}, delay)
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
private async ensureCacheClaimActive(): Promise<void> {
|
|
1721
|
+
if (this.cacheClaimRecovery) {
|
|
1722
|
+
await this.cacheClaimRecovery
|
|
1723
|
+
return
|
|
1724
|
+
}
|
|
1725
|
+
if (await this.renewCacheClaim(this.persistence.adapter)) return
|
|
1726
|
+
// Another caller may have started recovery while renewal was awaited.
|
|
1727
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
1728
|
+
if (!this.cacheClaimRecovery) {
|
|
1729
|
+
const recovery =
|
|
1730
|
+
this.restartSourceAfterScopedRecovery === undefined
|
|
1731
|
+
? this.reclaimStartupCacheGeneration()
|
|
1732
|
+
: this.startScopedRecovery()
|
|
1733
|
+
this.cacheClaimRecovery = recovery
|
|
1734
|
+
void recovery
|
|
1735
|
+
.finally(() => {
|
|
1736
|
+
if (this.cacheClaimRecovery === recovery) {
|
|
1737
|
+
this.cacheClaimRecovery = null
|
|
1738
|
+
}
|
|
1739
|
+
})
|
|
1740
|
+
.catch(() => undefined)
|
|
1741
|
+
}
|
|
1742
|
+
await this.cacheClaimRecovery
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
private scheduleCacheClaimRecovery(lifecycleGeneration: number): void {
|
|
1746
|
+
// Recovery may need the apply mutex held by the caller. Let that caller
|
|
1747
|
+
// release its turn before checking the claim and rotating the cache.
|
|
1748
|
+
queueMicrotask(() => {
|
|
1749
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
1750
|
+
void this.ensureCacheClaimActive()
|
|
1751
|
+
.then(() =>
|
|
1752
|
+
this.applyMutex.run(() => this.flushQueuedTxCommittedUnsafe()),
|
|
1753
|
+
)
|
|
1754
|
+
.catch((error) => {
|
|
1755
|
+
this.markTerminalFailure(error, lifecycleGeneration)
|
|
1756
|
+
})
|
|
1757
|
+
})
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
private async reclaimStartupCacheGeneration(): Promise<void> {
|
|
1761
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
1762
|
+
const previous = this.cacheGenerationClaim
|
|
1763
|
+
if (!previous) return
|
|
1764
|
+
if (!this.persistence.adapter.rotateCacheGeneration) {
|
|
1765
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
1766
|
+
`A managed on-demand cache adapter must rotate expired startup claims`,
|
|
1767
|
+
)
|
|
1768
|
+
}
|
|
1769
|
+
this.clearCacheClaimRenewal()
|
|
1770
|
+
this.coordinatorUnsubscribe?.()
|
|
1771
|
+
this.coordinatorUnsubscribe = null
|
|
1772
|
+
const rotated = await this.persistence.adapter.rotateCacheGeneration(
|
|
1773
|
+
this.collectionId,
|
|
1774
|
+
previous.claimId,
|
|
1775
|
+
undefined,
|
|
1776
|
+
previous.storageCollectionId,
|
|
1777
|
+
)
|
|
1778
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
1779
|
+
await this.persistence.adapter.releaseCacheGenerationClaim?.(
|
|
1780
|
+
rotated.claimId,
|
|
1781
|
+
)
|
|
1782
|
+
return
|
|
1783
|
+
}
|
|
1784
|
+
this.cacheGenerationClaim = rotated
|
|
1785
|
+
this.startupCacheGenerationRotated = true
|
|
1786
|
+
this.resetSequence++
|
|
1787
|
+
this.latestTerm = 0
|
|
1788
|
+
this.latestSeq = 0
|
|
1789
|
+
this.latestRowVersion = 0
|
|
1790
|
+
this.localTerm = 1
|
|
1791
|
+
this.localSeq = 0
|
|
1792
|
+
this.localRowVersion = 0
|
|
1793
|
+
this.persistedResumeGeneration = undefined
|
|
1794
|
+
this.persistedKeySetEvidence = { status: `incompatible` }
|
|
1795
|
+
this.scheduleCacheClaimRenewal(rotated)
|
|
1796
|
+
this.attachCoordinatorSubscription()
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
private async renewCacheClaim(
|
|
1800
|
+
adapter: HydrationPersistenceAdapter,
|
|
1801
|
+
): Promise<boolean> {
|
|
1802
|
+
const claim = this.cacheGenerationClaim
|
|
1803
|
+
if (
|
|
1804
|
+
!claim ||
|
|
1805
|
+
!adapter.renewCacheGenerationClaim ||
|
|
1806
|
+
this.scopedRecoveryPending
|
|
1807
|
+
)
|
|
1808
|
+
return true
|
|
1809
|
+
if (
|
|
1810
|
+
claim.expiresAtMs !== undefined &&
|
|
1811
|
+
claim.expiresAtMs - this.cacheGenerationNow() >
|
|
1812
|
+
CACHE_CLAIM_RENEW_WINDOW_MS
|
|
1813
|
+
) {
|
|
1814
|
+
// Durable operations check their claim at the adapter boundary. Keep
|
|
1815
|
+
// the background timer armed without writing the same live claim for
|
|
1816
|
+
// every foreground operation.
|
|
1817
|
+
if (this.cacheClaimRenewTimer === null) {
|
|
1818
|
+
this.scheduleCacheClaimRenewal(claim)
|
|
1819
|
+
}
|
|
1820
|
+
return true
|
|
1821
|
+
}
|
|
1822
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
1823
|
+
const expiresAtMs = await adapter.renewCacheGenerationClaim(
|
|
1824
|
+
claim.storageCollectionId,
|
|
1825
|
+
claim.claimId,
|
|
1826
|
+
)
|
|
1827
|
+
if (
|
|
1828
|
+
lifecycleGeneration !== this.lifecycleGeneration ||
|
|
1829
|
+
this.cacheGenerationClaim?.claimId !== claim.claimId ||
|
|
1830
|
+
this.cacheGenerationClaim.storageCollectionId !==
|
|
1831
|
+
claim.storageCollectionId
|
|
1832
|
+
) {
|
|
1833
|
+
return true
|
|
1834
|
+
}
|
|
1835
|
+
if (expiresAtMs === undefined) {
|
|
1836
|
+
return false
|
|
1837
|
+
}
|
|
1838
|
+
this.cacheGenerationClaim = { ...claim, expiresAtMs }
|
|
1839
|
+
this.scheduleCacheClaimRenewal(this.cacheGenerationClaim)
|
|
1840
|
+
return true
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1440
1843
|
setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
|
|
1441
1844
|
this.advanceLifecycle()
|
|
1442
1845
|
this.syncErrorReported = false
|
|
@@ -1451,6 +1854,17 @@ class PersistedCollectionRuntime<
|
|
|
1451
1854
|
}
|
|
1452
1855
|
}
|
|
1453
1856
|
|
|
1857
|
+
setSourceRestartAfterScopedRecovery(
|
|
1858
|
+
restart:
|
|
1859
|
+
((cacheRotated: Promise<void>) => void | Promise<void>) | undefined,
|
|
1860
|
+
): void {
|
|
1861
|
+
this.restartSourceAfterScopedRecovery = restart ?? null
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
clearSourceRestartAfterScopedRecovery(): void {
|
|
1865
|
+
this.restartSourceAfterScopedRecovery = undefined
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1454
1868
|
reportSyncError(error: unknown): unknown {
|
|
1455
1869
|
const markError = this.syncControls.markError
|
|
1456
1870
|
if (this.syncErrorReported) return this.reportedSyncError
|
|
@@ -1468,9 +1882,10 @@ class PersistedCollectionRuntime<
|
|
|
1468
1882
|
|
|
1469
1883
|
registerRemoteSubsetOwner(owner: RemoteSubsetOwner): void {
|
|
1470
1884
|
this.remoteSubsetOwnerUnsubscribe?.()
|
|
1885
|
+
this.remoteSubsetOwner = owner
|
|
1471
1886
|
this.remoteSubsetOwnerUnsubscribe =
|
|
1472
1887
|
this.persistence.coordinator.registerRemoteSubsetOwner(
|
|
1473
|
-
this.
|
|
1888
|
+
this.storageCollectionId,
|
|
1474
1889
|
owner,
|
|
1475
1890
|
)
|
|
1476
1891
|
}
|
|
@@ -1504,6 +1919,31 @@ class PersistedCollectionRuntime<
|
|
|
1504
1919
|
)
|
|
1505
1920
|
}
|
|
1506
1921
|
|
|
1922
|
+
// An ensure reply settles a follower's load (law 7). A source's loadSubset
|
|
1923
|
+
// must await every commit receipt that establishes its result, so the
|
|
1924
|
+
// leader broadcasts those commits before it replies on the same channel.
|
|
1925
|
+
// They are already queued on the apply mutex; settle after they show here.
|
|
1926
|
+
private async applyLeaderCommitsBeforeReply(
|
|
1927
|
+
options: LoadSubsetOptions,
|
|
1928
|
+
): Promise<void> {
|
|
1929
|
+
try {
|
|
1930
|
+
// Commits kept while the claim was expired apply after it is active.
|
|
1931
|
+
if (this.hasExpiredCacheClaim() || this.cacheClaimRecovery) {
|
|
1932
|
+
await this.ensureCacheClaimActive()
|
|
1933
|
+
}
|
|
1934
|
+
await this.applyMutex.run(() => this.flushQueuedTxCommittedUnsafe())
|
|
1935
|
+
// A broadcast that failed to apply made the runtime terminal; its rows
|
|
1936
|
+
// are not visible, so the load must not settle successfully.
|
|
1937
|
+
this.throwIfTerminal()
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
// A terminal runtime releases the acquisition the owner granted the
|
|
1940
|
+
// load. Otherwise, as after a failed ensure, the demand retries.
|
|
1941
|
+
if (this.getCurrentTerminalFailure()) this.unloadSubset(options)
|
|
1942
|
+
else this.queueRemoteSubsetEnsure(options)
|
|
1943
|
+
throw error
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1507
1947
|
private clearSyncControls(): void {
|
|
1508
1948
|
this.syncControls = {
|
|
1509
1949
|
begin: null,
|
|
@@ -1579,21 +2019,63 @@ class PersistedCollectionRuntime<
|
|
|
1579
2019
|
return this.internalApplyDepth > 0
|
|
1580
2020
|
}
|
|
1581
2021
|
|
|
2022
|
+
isScopedRecoveryPending(): boolean {
|
|
2023
|
+
return this.scopedRecoveryPending
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
hasBoundCollection(): boolean {
|
|
2027
|
+
return this.boundCollection !== null
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1582
2030
|
setCollection(
|
|
1583
2031
|
collection: Collection<T, TKey, PersistedCollectionUtils>,
|
|
2032
|
+
syncControls: SyncControlFns<T, TKey>,
|
|
1584
2033
|
): void {
|
|
2034
|
+
if (
|
|
2035
|
+
(
|
|
2036
|
+
collection.config as typeof collection.config & {
|
|
2037
|
+
[collectionOptionsClaim]?: unknown
|
|
2038
|
+
}
|
|
2039
|
+
)[collectionOptionsClaim] !== this.optionsClaim
|
|
2040
|
+
) {
|
|
2041
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
2042
|
+
`Persisted sync belongs to its options Collection`,
|
|
2043
|
+
)
|
|
2044
|
+
}
|
|
2045
|
+
if (this.boundCollection && this.boundCollection !== collection) {
|
|
2046
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
2047
|
+
`Persisted options cannot own more than one Collection`,
|
|
2048
|
+
)
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const previousCollection = this.collection
|
|
2052
|
+
const previousControls = this.syncControls
|
|
2053
|
+
this.setSyncControls(syncControls)
|
|
1585
2054
|
if (this.collection === collection) {
|
|
2055
|
+
this.boundCollection ??= collection
|
|
1586
2056
|
return
|
|
1587
2057
|
}
|
|
1588
|
-
|
|
1589
2058
|
this.collection = collection
|
|
1590
|
-
|
|
2059
|
+
try {
|
|
2060
|
+
if (
|
|
2061
|
+
!this.persistence.adapter.claimCacheGeneration ||
|
|
2062
|
+
this.mode !== `sync-present` ||
|
|
2063
|
+
this.syncMode !== `on-demand`
|
|
2064
|
+
) {
|
|
2065
|
+
this.attachCoordinatorSubscription()
|
|
2066
|
+
}
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
this.collection = previousCollection
|
|
2069
|
+
this.syncControls = previousControls
|
|
2070
|
+
throw error
|
|
2071
|
+
}
|
|
2072
|
+
this.boundCollection ??= collection
|
|
1591
2073
|
}
|
|
1592
2074
|
|
|
1593
2075
|
getLeadershipState(): PersistedCollectionLeadershipState {
|
|
1594
2076
|
return {
|
|
1595
2077
|
nodeId: this.persistence.coordinator.getNodeId(),
|
|
1596
|
-
isLeader: this.persistence.coordinator.isLeader(this.
|
|
2078
|
+
isLeader: this.persistence.coordinator.isLeader(this.storageCollectionId),
|
|
1597
2079
|
}
|
|
1598
2080
|
}
|
|
1599
2081
|
|
|
@@ -1614,6 +2096,7 @@ class PersistedCollectionRuntime<
|
|
|
1614
2096
|
}
|
|
1615
2097
|
|
|
1616
2098
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
2099
|
+
this.startupCacheGenerationRotated = false
|
|
1617
2100
|
const isRestart = this.hasAttemptedStartup
|
|
1618
2101
|
this.hasAttemptedStartup = true
|
|
1619
2102
|
let resolveStartupMetadata!: () => void
|
|
@@ -1625,6 +2108,12 @@ class PersistedCollectionRuntime<
|
|
|
1625
2108
|
void this.startupMetadataPromise.catch(() => undefined)
|
|
1626
2109
|
|
|
1627
2110
|
this.startPromise = (async () => {
|
|
2111
|
+
await this.claimCacheGeneration(lifecycleGeneration)
|
|
2112
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
2113
|
+
resolveStartupMetadata()
|
|
2114
|
+
return
|
|
2115
|
+
}
|
|
2116
|
+
let metadataExpired = false
|
|
1628
2117
|
const loadStartupMetadata = async (
|
|
1629
2118
|
adapter: HydrationPersistenceAdapter,
|
|
1630
2119
|
) => {
|
|
@@ -1634,9 +2123,24 @@ class PersistedCollectionRuntime<
|
|
|
1634
2123
|
}
|
|
1635
2124
|
|
|
1636
2125
|
try {
|
|
1637
|
-
|
|
2126
|
+
if (
|
|
2127
|
+
!(await this.loadStartupMetadataInternal(
|
|
2128
|
+
lifecycleGeneration,
|
|
2129
|
+
adapter,
|
|
2130
|
+
))
|
|
2131
|
+
) {
|
|
2132
|
+
metadataExpired = true
|
|
2133
|
+
return false
|
|
2134
|
+
}
|
|
1638
2135
|
return lifecycleGeneration === this.lifecycleGeneration
|
|
1639
2136
|
} catch (error) {
|
|
2137
|
+
// SQLite can reject its second claim check after a startup read
|
|
2138
|
+
// entered with a live claim. That old cache cannot authorize source
|
|
2139
|
+
// entry; rotate it after the hydration scope releases its scheduler.
|
|
2140
|
+
if (this.cacheGenerationClaim && this.hasExpiredCacheClaim()) {
|
|
2141
|
+
metadataExpired = true
|
|
2142
|
+
return false
|
|
2143
|
+
}
|
|
1640
2144
|
rejectStartupMetadata(error)
|
|
1641
2145
|
throw error
|
|
1642
2146
|
}
|
|
@@ -1653,50 +2157,82 @@ class PersistedCollectionRuntime<
|
|
|
1653
2157
|
this.persistence.adapter.runInHydrationScope !== undefined &&
|
|
1654
2158
|
(!isRestart ||
|
|
1655
2159
|
(this.persistence.adapter.isHydrationScopeScheduled?.() ?? true))
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
adapter
|
|
1663
|
-
|
|
1664
|
-
|
|
2160
|
+
for (;;) {
|
|
2161
|
+
metadataExpired = false
|
|
2162
|
+
const storageAtAttempt = this.storageCollectionId
|
|
2163
|
+
if (scheduleStartupAsOneHydrate) {
|
|
2164
|
+
startup = await this.applyMutex.run(async () => {
|
|
2165
|
+
const result = await this.runInHydrationScope(async (adapter) => {
|
|
2166
|
+
if (!(await loadStartupMetadata(adapter))) return undefined
|
|
2167
|
+
return this.startInternal(
|
|
2168
|
+
lifecycleGeneration,
|
|
2169
|
+
adapter,
|
|
2170
|
+
resolveStartupMetadata,
|
|
2171
|
+
)
|
|
2172
|
+
})
|
|
2173
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
2174
|
+
await this.flushQueuedTxCommittedUnsafe()
|
|
2175
|
+
}
|
|
2176
|
+
return result
|
|
1665
2177
|
})
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
2178
|
+
} else {
|
|
2179
|
+
// Preserve the existing unscheduled-adapter lifecycle contract: a
|
|
2180
|
+
// replacement upstream may start while stale hydration is settling.
|
|
2181
|
+
if (await loadStartupMetadata(this.persistence.adapter)) {
|
|
2182
|
+
if (this.persistence.adapter.isHydrationScopeScheduled?.()) {
|
|
2183
|
+
startup = await this.applyMutex.run(async () => {
|
|
2184
|
+
const result = await this.runInHydrationScope((adapter) =>
|
|
2185
|
+
this.startInternal(
|
|
2186
|
+
lifecycleGeneration,
|
|
2187
|
+
adapter,
|
|
2188
|
+
resolveStartupMetadata,
|
|
2189
|
+
),
|
|
2190
|
+
)
|
|
2191
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
2192
|
+
await this.flushQueuedTxCommittedUnsafe()
|
|
2193
|
+
}
|
|
2194
|
+
return result
|
|
2195
|
+
})
|
|
2196
|
+
} else {
|
|
2197
|
+
startup = await this.startInternal(
|
|
2198
|
+
lifecycleGeneration,
|
|
2199
|
+
this.persistence.adapter,
|
|
2200
|
+
resolveStartupMetadata,
|
|
1683
2201
|
)
|
|
1684
2202
|
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
1685
2203
|
await this.flushQueuedTxCommittedUnsafe()
|
|
1686
2204
|
}
|
|
1687
|
-
return result
|
|
1688
|
-
})
|
|
1689
|
-
} else {
|
|
1690
|
-
startup = await this.startInternal(
|
|
1691
|
-
lifecycleGeneration,
|
|
1692
|
-
this.persistence.adapter,
|
|
1693
|
-
resolveStartupMetadata,
|
|
1694
|
-
)
|
|
1695
|
-
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
1696
|
-
await this.flushQueuedTxCommittedUnsafe()
|
|
1697
2205
|
}
|
|
1698
2206
|
}
|
|
1699
2207
|
}
|
|
2208
|
+
if (
|
|
2209
|
+
this.cacheGenerationClaim &&
|
|
2210
|
+
this.syncMode === `on-demand` &&
|
|
2211
|
+
// Hydration may have changed this through loadStartupMetadata().
|
|
2212
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
2213
|
+
!metadataExpired
|
|
2214
|
+
) {
|
|
2215
|
+
// Source entry must wait until any private rotation started during
|
|
2216
|
+
// index bootstrap has settled. The source has no restart capability
|
|
2217
|
+
// until its sync callback returns.
|
|
2218
|
+
await this.ensureCacheClaimActive()
|
|
2219
|
+
metadataExpired = storageAtAttempt !== this.storageCollectionId
|
|
2220
|
+
}
|
|
2221
|
+
if (!metadataExpired) break
|
|
2222
|
+
// A startup read cannot reacquire the claim inside its hydration
|
|
2223
|
+
// scope: claim acquisition uses the regular scheduler lane.
|
|
2224
|
+
await this.ensureCacheClaimActive()
|
|
2225
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
2226
|
+
resolveStartupMetadata()
|
|
2227
|
+
return
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
if (
|
|
2231
|
+
this.cacheGenerationClaim &&
|
|
2232
|
+
this.syncMode === `on-demand` &&
|
|
2233
|
+
lifecycleGeneration === this.lifecycleGeneration
|
|
2234
|
+
) {
|
|
2235
|
+
resolveStartupMetadata()
|
|
1700
2236
|
}
|
|
1701
2237
|
if (
|
|
1702
2238
|
startup !== undefined &&
|
|
@@ -1740,6 +2276,7 @@ class PersistedCollectionRuntime<
|
|
|
1740
2276
|
await this.ensureStarted()
|
|
1741
2277
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
1742
2278
|
if (this.syncMode !== `on-demand`) return
|
|
2279
|
+
if (this.scopedRecovery) return
|
|
1743
2280
|
|
|
1744
2281
|
const appliedCursor = await this.applyMutex.run(async () => {
|
|
1745
2282
|
const result = await this.runInHydrationScope((adapter) =>
|
|
@@ -1750,6 +2287,11 @@ class PersistedCollectionRuntime<
|
|
|
1750
2287
|
}
|
|
1751
2288
|
return result
|
|
1752
2289
|
})
|
|
2290
|
+
if (appliedCursor === false) {
|
|
2291
|
+
// Rotation needs the apply mutex, so recover only after releasing it.
|
|
2292
|
+
await this.ensureCacheClaimActive()
|
|
2293
|
+
return
|
|
2294
|
+
}
|
|
1753
2295
|
if (
|
|
1754
2296
|
appliedCursor !== undefined &&
|
|
1755
2297
|
lifecycleGeneration === this.lifecycleGeneration
|
|
@@ -1771,15 +2313,39 @@ class PersistedCollectionRuntime<
|
|
|
1771
2313
|
this.resumeCertificationPromise = (async () => {
|
|
1772
2314
|
await this.ensureStarted()
|
|
1773
2315
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2316
|
+
if (this.scopedRecovery) return
|
|
2317
|
+
|
|
2318
|
+
const storageCollectionId = this.storageCollectionId
|
|
2319
|
+
const claimId = this.cacheGenerationClaim?.claimId
|
|
2320
|
+
const certificationIsCurrent = () =>
|
|
2321
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
2322
|
+
!this.scopedRecovery &&
|
|
2323
|
+
storageCollectionId === this.storageCollectionId &&
|
|
2324
|
+
claimId === this.cacheGenerationClaim?.claimId
|
|
2325
|
+
let snapshot: Awaited<
|
|
2326
|
+
ReturnType<PersistenceAdapter[`loadResumeSnapshot`]>
|
|
2327
|
+
>
|
|
2328
|
+
try {
|
|
2329
|
+
snapshot = await this.persistence.adapter.loadResumeSnapshot(
|
|
2330
|
+
this.storageTargetFor(storageCollectionId, claimId),
|
|
2331
|
+
{
|
|
2332
|
+
requiredIndexSignatures: this.getRequiredIndexSignatures(),
|
|
2333
|
+
includeRows: false,
|
|
2334
|
+
cacheGenerationClaimId: claimId,
|
|
2335
|
+
},
|
|
2336
|
+
)
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
if (!certificationIsCurrent()) return
|
|
2339
|
+
if (!claimId || !this.hasExpiredCacheClaim()) throw error
|
|
2340
|
+
await this.ensureCacheClaimActive()
|
|
2341
|
+
return
|
|
2342
|
+
}
|
|
2343
|
+
if (!certificationIsCurrent()) return
|
|
2344
|
+
if (claimId && !(await this.renewCacheClaim(this.persistence.adapter))) {
|
|
2345
|
+
await this.ensureCacheClaimActive()
|
|
2346
|
+
return
|
|
2347
|
+
}
|
|
2348
|
+
if (!certificationIsCurrent()) return
|
|
1783
2349
|
this.bindResumeSnapshotEvidence(snapshot)
|
|
1784
2350
|
})()
|
|
1785
2351
|
return this.resumeCertificationPromise
|
|
@@ -1789,6 +2355,187 @@ class PersistedCollectionRuntime<
|
|
|
1789
2355
|
return this.persistedKeySetEvidence
|
|
1790
2356
|
}
|
|
1791
2357
|
|
|
2358
|
+
hasManagedCacheGeneration(): boolean {
|
|
2359
|
+
return this.cacheGenerationClaim !== undefined
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
hasRotatedStartupCacheGeneration(): boolean {
|
|
2363
|
+
return this.startupCacheGenerationRotated
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
async startScopedRecovery(resetMetadata?: {
|
|
2367
|
+
key: string
|
|
2368
|
+
value: unknown
|
|
2369
|
+
}): Promise<void> {
|
|
2370
|
+
this.throwIfTerminal()
|
|
2371
|
+
this.clearCacheClaimRenewal()
|
|
2372
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
2373
|
+
const restartSource = this.restartSourceAfterScopedRecovery
|
|
2374
|
+
// Invalidate an in-flight old-cache read before yielding to the mutex.
|
|
2375
|
+
this.scopedRecovery = true
|
|
2376
|
+
this.scopedRecoveryPending = true
|
|
2377
|
+
const recoverySequence = ++this.scopedRecoverySequence
|
|
2378
|
+
this.hydratedDemands.clear()
|
|
2379
|
+
this.resetSequence++
|
|
2380
|
+
let resolveCacheRotated: (() => void) | undefined
|
|
2381
|
+
let rejectCacheRotated: ((error: unknown) => void) | undefined
|
|
2382
|
+
let sourceRestart: Promise<void> | undefined
|
|
2383
|
+
try {
|
|
2384
|
+
if (restartSource) {
|
|
2385
|
+
const cacheRotated = new Promise<void>((resolve, reject) => {
|
|
2386
|
+
resolveCacheRotated = resolve
|
|
2387
|
+
rejectCacheRotated = reject
|
|
2388
|
+
})
|
|
2389
|
+
void cacheRotated.catch(() => undefined)
|
|
2390
|
+
// The source must retire old callbacks before this call returns.
|
|
2391
|
+
sourceRestart = Promise.resolve(restartSource(cacheRotated))
|
|
2392
|
+
void sourceRestart.catch(() => undefined)
|
|
2393
|
+
}
|
|
2394
|
+
await this.applyMutex.run(async () => {
|
|
2395
|
+
this.throwIfLifecycleReplaced(lifecycleGeneration)
|
|
2396
|
+
const claim = this.cacheGenerationClaim
|
|
2397
|
+
if (claim && this.persistence.adapter.rotateCacheGeneration) {
|
|
2398
|
+
const previousStorageId = claim.storageCollectionId
|
|
2399
|
+
const rotated = await this.persistence.adapter.rotateCacheGeneration(
|
|
2400
|
+
this.collectionId,
|
|
2401
|
+
claim.claimId,
|
|
2402
|
+
resetMetadata,
|
|
2403
|
+
previousStorageId,
|
|
2404
|
+
)
|
|
2405
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
2406
|
+
// Cleanup released the old claim while rotation was in flight.
|
|
2407
|
+
// The returned claim belongs to this abandoned recovery too.
|
|
2408
|
+
await this.persistence.adapter.releaseCacheGenerationClaim?.(
|
|
2409
|
+
rotated.claimId,
|
|
2410
|
+
)
|
|
2411
|
+
throw new SyncTransactionAbortedError()
|
|
2412
|
+
}
|
|
2413
|
+
for (const options of this.activeSubsets.values()) {
|
|
2414
|
+
// The retired generation has a separate coordinator route. A
|
|
2415
|
+
// paused old leader cannot hold up the new cache's demands.
|
|
2416
|
+
void this.persistence.coordinator
|
|
2417
|
+
.requestReleaseRemoteSubset(
|
|
2418
|
+
previousStorageId,
|
|
2419
|
+
this.rotatedAcquisitions.get(options) ?? options,
|
|
2420
|
+
)
|
|
2421
|
+
.catch(() => undefined)
|
|
2422
|
+
if (options.signal?.aborted) {
|
|
2423
|
+
this.rotatedAcquisitions.delete(options)
|
|
2424
|
+
continue
|
|
2425
|
+
}
|
|
2426
|
+
this.rotatedAcquisitions.set(options, {
|
|
2427
|
+
...options,
|
|
2428
|
+
refetch: true,
|
|
2429
|
+
})
|
|
2430
|
+
}
|
|
2431
|
+
this.remoteSubsetOwnerUnsubscribe?.()
|
|
2432
|
+
this.remoteSubsetOwnerUnsubscribe = null
|
|
2433
|
+
this.coordinatorUnsubscribe?.()
|
|
2434
|
+
this.coordinatorUnsubscribe = null
|
|
2435
|
+
this.cacheGenerationClaim = rotated
|
|
2436
|
+
this.latestTerm = 0
|
|
2437
|
+
this.latestSeq = 0
|
|
2438
|
+
this.latestRowVersion = 0
|
|
2439
|
+
this.publicRowVersion = 0
|
|
2440
|
+
this.resetTermFence = undefined
|
|
2441
|
+
this.localTerm = 1
|
|
2442
|
+
this.localSeq = 0
|
|
2443
|
+
this.localRowVersion = 0
|
|
2444
|
+
this.queuedTxCommitted.length = 0
|
|
2445
|
+
this.persistedResumeGeneration = undefined
|
|
2446
|
+
this.persistedKeySetEvidence = { status: `incompatible` }
|
|
2447
|
+
this.resumeCertificationPromise = null
|
|
2448
|
+
this.resumeGenerationOwner = Symbol(
|
|
2449
|
+
`persisted resume generation owner`,
|
|
2450
|
+
)
|
|
2451
|
+
this.attachCoordinatorSubscription()
|
|
2452
|
+
if (this.remoteSubsetOwner) {
|
|
2453
|
+
this.registerRemoteSubsetOwner(this.remoteSubsetOwner)
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if (this.syncControls.begin && this.syncControls.commit) {
|
|
2457
|
+
const applied = this.withInternalApply(() => {
|
|
2458
|
+
this.syncControls.begin?.()
|
|
2459
|
+
this.syncControls.truncate?.({ markReady: false })
|
|
2460
|
+
if (resetMetadata) {
|
|
2461
|
+
this.syncControls.metadata?.collection.set(
|
|
2462
|
+
resetMetadata.key,
|
|
2463
|
+
resetMetadata.value,
|
|
2464
|
+
)
|
|
2465
|
+
}
|
|
2466
|
+
return this.syncControls.commit?.() ?? true
|
|
2467
|
+
})
|
|
2468
|
+
await whenSyncAccepted(applied)
|
|
2469
|
+
if (
|
|
2470
|
+
!this.cacheGenerationClaim &&
|
|
2471
|
+
recoverySequence === this.scopedRecoverySequence
|
|
2472
|
+
) {
|
|
2473
|
+
this.scopedRecoveryPending = false
|
|
2474
|
+
}
|
|
2475
|
+
await this.runInHydrationScope(async (adapter) => {
|
|
2476
|
+
if (this.recoverQueuedAfterLocalLoadFailure) {
|
|
2477
|
+
this.recoverQueuedAfterLocalLoadFailure = false
|
|
2478
|
+
await this.recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
|
|
2479
|
+
lifecycleGeneration,
|
|
2480
|
+
adapter,
|
|
2481
|
+
)
|
|
2482
|
+
} else {
|
|
2483
|
+
await this.flushQueuedHydrationTransactionsUnsafe(adapter)
|
|
2484
|
+
}
|
|
2485
|
+
})
|
|
2486
|
+
await this.flushQueuedTxCommittedUnsafe()
|
|
2487
|
+
}
|
|
2488
|
+
if (recoverySequence === this.scopedRecoverySequence) {
|
|
2489
|
+
// Older overlapping rotations leave quarantine in place. The final
|
|
2490
|
+
// rotation rebuilds the current index registry before releasing the
|
|
2491
|
+
// mutex; later index events can then target only its storage ID.
|
|
2492
|
+
this.scopedRecoveryPending = false
|
|
2493
|
+
if (this.cacheGenerationClaim) {
|
|
2494
|
+
const currentIndexes = this.collection?.getIndexMetadata() ?? []
|
|
2495
|
+
const completedIndexes = await this.bootstrapPersistedIndexes(
|
|
2496
|
+
currentIndexes,
|
|
2497
|
+
this.persistence.adapter,
|
|
2498
|
+
)
|
|
2499
|
+
await this.requestCoordinatorPersistedIndexes(
|
|
2500
|
+
currentIndexes,
|
|
2501
|
+
completedIndexes,
|
|
2502
|
+
)
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
})
|
|
2506
|
+
this.throwIfLifecycleReplaced(lifecycleGeneration)
|
|
2507
|
+
resolveCacheRotated?.()
|
|
2508
|
+
await sourceRestart
|
|
2509
|
+
if (
|
|
2510
|
+
recoverySequence === this.scopedRecoverySequence &&
|
|
2511
|
+
this.cacheGenerationClaim
|
|
2512
|
+
) {
|
|
2513
|
+
this.scheduleCacheClaimRenewal(this.cacheGenerationClaim)
|
|
2514
|
+
}
|
|
2515
|
+
// A newer rotation owns reacquisition of the active demands. Dispatching
|
|
2516
|
+
// them from this older recovery would fetch against its pending cache.
|
|
2517
|
+
if (recoverySequence !== this.scopedRecoverySequence) return
|
|
2518
|
+
for (const options of this.activeSubsets.values()) {
|
|
2519
|
+
if (options.signal?.aborted) continue
|
|
2520
|
+
const rotatedOptions = this.rotatedAcquisitions.get(options)
|
|
2521
|
+
if (rotatedOptions) {
|
|
2522
|
+
const ensure = this.persistence.coordinator.requestEnsureRemoteSubset(
|
|
2523
|
+
this.storageCollectionId,
|
|
2524
|
+
rotatedOptions,
|
|
2525
|
+
)
|
|
2526
|
+
this.rotatedEnsures.set(options, ensure)
|
|
2527
|
+
void ensure.catch((error) => this.reportSyncError(error))
|
|
2528
|
+
} else {
|
|
2529
|
+
this.refreshScopedSubsets()
|
|
2530
|
+
break
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
rejectCacheRotated?.(error)
|
|
2535
|
+
throw this.markTerminalFailure(error, lifecycleGeneration)
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
1792
2539
|
getResumeGenerationOwner(): symbol {
|
|
1793
2540
|
return this.resumeGenerationOwner
|
|
1794
2541
|
}
|
|
@@ -1796,7 +2543,7 @@ class PersistedCollectionRuntime<
|
|
|
1796
2543
|
private async hydrateBaseline(
|
|
1797
2544
|
lifecycleGeneration: number,
|
|
1798
2545
|
adapter: HydrationPersistenceAdapter,
|
|
1799
|
-
): Promise<number | undefined> {
|
|
2546
|
+
): Promise<number | undefined | false> {
|
|
1800
2547
|
if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
|
|
1801
2548
|
|
|
1802
2549
|
// The baseline shares the unconstrained demand key. Its lease is never
|
|
@@ -1804,7 +2551,7 @@ class PersistedCollectionRuntime<
|
|
|
1804
2551
|
const baseline = {}
|
|
1805
2552
|
this.activeSubsets.set(this.getSubsetKey(baseline), baseline)
|
|
1806
2553
|
const appliedCursor = this.appliedReceiptSequence
|
|
1807
|
-
await this.hydrateSubsetUnsafe(
|
|
2554
|
+
const hydrated = await this.hydrateSubsetUnsafe(
|
|
1808
2555
|
baseline,
|
|
1809
2556
|
{
|
|
1810
2557
|
lifecycleGeneration,
|
|
@@ -1812,6 +2559,7 @@ class PersistedCollectionRuntime<
|
|
|
1812
2559
|
},
|
|
1813
2560
|
adapter,
|
|
1814
2561
|
)
|
|
2562
|
+
if (!hydrated) return false
|
|
1815
2563
|
return lifecycleGeneration === this.lifecycleGeneration
|
|
1816
2564
|
? appliedCursor
|
|
1817
2565
|
: undefined
|
|
@@ -1849,17 +2597,19 @@ class PersistedCollectionRuntime<
|
|
|
1849
2597
|
)
|
|
1850
2598
|
if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
|
|
1851
2599
|
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1855
|
-
|
|
2600
|
+
// Eager and unmanaged sources start at the hydrate boundary so their first
|
|
2601
|
+
// transaction binds to its sequence. A managed on-demand source waits
|
|
2602
|
+
// until the scope exits and any concurrent private rotation settles.
|
|
2603
|
+
if (!this.cacheGenerationClaim || this.syncMode !== `on-demand`) {
|
|
2604
|
+
onStartupMetadataLoaded()
|
|
2605
|
+
}
|
|
1856
2606
|
const appliedCursor =
|
|
1857
2607
|
this.syncMode !== `on-demand`
|
|
1858
2608
|
? await this.hydrateBaseline(lifecycleGeneration, adapter)
|
|
1859
2609
|
: undefined
|
|
1860
2610
|
return lifecycleGeneration === this.lifecycleGeneration
|
|
1861
2611
|
? {
|
|
1862
|
-
appliedCursor,
|
|
2612
|
+
appliedCursor: appliedCursor === false ? undefined : appliedCursor,
|
|
1863
2613
|
indexBootstrapSnapshot,
|
|
1864
2614
|
completedLocalIndexSignatures,
|
|
1865
2615
|
}
|
|
@@ -1869,11 +2619,23 @@ class PersistedCollectionRuntime<
|
|
|
1869
2619
|
private async loadStartupMetadataInternal(
|
|
1870
2620
|
lifecycleGeneration: number,
|
|
1871
2621
|
adapter: HydrationPersistenceAdapter,
|
|
1872
|
-
): Promise<
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2622
|
+
): Promise<boolean> {
|
|
2623
|
+
const claimId = this.cacheGenerationClaim?.claimId
|
|
2624
|
+
const storageCollectionId = this.storageCollectionId
|
|
2625
|
+
const snapshot = await adapter.loadResumeSnapshot(
|
|
2626
|
+
this.storageTargetFor(storageCollectionId, claimId),
|
|
2627
|
+
{
|
|
2628
|
+
includeRows: false,
|
|
2629
|
+
cacheGenerationClaimId: claimId,
|
|
2630
|
+
},
|
|
2631
|
+
)
|
|
2632
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return false
|
|
2633
|
+
if (claimId !== this.cacheGenerationClaim?.claimId) return false
|
|
2634
|
+
if (storageCollectionId !== this.storageCollectionId) return false
|
|
2635
|
+
if (claimId && !(await this.renewCacheClaim(adapter))) return false
|
|
2636
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return false
|
|
2637
|
+
if (claimId !== this.cacheGenerationClaim?.claimId) return false
|
|
2638
|
+
if (storageCollectionId !== this.storageCollectionId) return false
|
|
1877
2639
|
this.persistedResumeGeneration = this.getResumeSnapshotGeneration(snapshot)
|
|
1878
2640
|
this.persistedKeySetEvidence = snapshot.keySet
|
|
1879
2641
|
this.observeStreamPosition(
|
|
@@ -1885,6 +2647,7 @@ class PersistedCollectionRuntime<
|
|
|
1885
2647
|
snapshot.collectionMetadata,
|
|
1886
2648
|
)
|
|
1887
2649
|
await whenSyncAccepted(applied)
|
|
2650
|
+
return true
|
|
1888
2651
|
}
|
|
1889
2652
|
|
|
1890
2653
|
private async loadCollectionMetadataSnapshot(
|
|
@@ -1894,7 +2657,9 @@ class PersistedCollectionRuntime<
|
|
|
1894
2657
|
return []
|
|
1895
2658
|
}
|
|
1896
2659
|
|
|
1897
|
-
return adapter.loadCollectionMetadata(this.
|
|
2660
|
+
return adapter.loadCollectionMetadata(this.storageTargetFor(), {
|
|
2661
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
2662
|
+
})
|
|
1898
2663
|
}
|
|
1899
2664
|
|
|
1900
2665
|
private replaceCollectionMetadataSnapshot(
|
|
@@ -1908,35 +2673,39 @@ class PersistedCollectionRuntime<
|
|
|
1908
2673
|
return true
|
|
1909
2674
|
}
|
|
1910
2675
|
|
|
1911
|
-
const nextMetadata = new Map(
|
|
1912
|
-
collectionMetadata.map(({ key, value }) => [key, value]),
|
|
1913
|
-
)
|
|
1914
|
-
const currentKeys = this.syncControls.metadata.collection
|
|
1915
|
-
.list()
|
|
1916
|
-
.map(({ key }) => key)
|
|
1917
|
-
|
|
1918
2676
|
return this.withInternalApply(() => {
|
|
1919
2677
|
this.syncControls.begin?.()
|
|
1920
|
-
|
|
1921
|
-
currentKeys.forEach((key) => {
|
|
1922
|
-
if (!nextMetadata.has(key)) {
|
|
1923
|
-
this.syncControls.metadata?.collection.delete(key)
|
|
1924
|
-
}
|
|
1925
|
-
})
|
|
1926
|
-
|
|
1927
|
-
nextMetadata.forEach((value, key) => {
|
|
1928
|
-
this.syncControls.metadata?.collection.set(key, value)
|
|
1929
|
-
})
|
|
1930
|
-
|
|
2678
|
+
this.applyCollectionMetadataSnapshot(collectionMetadata)
|
|
1931
2679
|
return this.syncControls.commit?.() ?? true
|
|
1932
2680
|
})
|
|
1933
2681
|
}
|
|
1934
2682
|
|
|
2683
|
+
private applyCollectionMetadataSnapshot(
|
|
2684
|
+
collectionMetadata: Array<{ key: string; value: unknown }>,
|
|
2685
|
+
): void {
|
|
2686
|
+
const metadata = this.syncControls.metadata
|
|
2687
|
+
if (!metadata) return
|
|
2688
|
+
const next = new Map(
|
|
2689
|
+
collectionMetadata.map(({ key, value }) => [key, value]),
|
|
2690
|
+
)
|
|
2691
|
+
for (const { key } of metadata.collection.list()) {
|
|
2692
|
+
if (!next.has(key)) metadata.collection.delete(key)
|
|
2693
|
+
}
|
|
2694
|
+
for (const [key, value] of next) metadata.collection.set(key, value)
|
|
2695
|
+
}
|
|
2696
|
+
|
|
1935
2697
|
async loadSubset(
|
|
1936
2698
|
options: LoadSubsetOptions,
|
|
1937
2699
|
upstreamLoadSubset?: LoadSubsetFn,
|
|
2700
|
+
onHydrationStarted?: () => void,
|
|
2701
|
+
isDemandRetained?: () => boolean,
|
|
1938
2702
|
): Promise<void> {
|
|
1939
2703
|
this.throwIfTerminal()
|
|
2704
|
+
await this.ensureCacheClaimActive()
|
|
2705
|
+
// The caller can retire demand while claim renewal or rotation is awaited.
|
|
2706
|
+
// No acquisition may start after that retirement.
|
|
2707
|
+
if (options.signal?.aborted || (isDemandRetained && !isDemandRetained()))
|
|
2708
|
+
return
|
|
1940
2709
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
1941
2710
|
const subsetKey = this.getSubsetKey(options)
|
|
1942
2711
|
const truncateGeneration = this.sourceTruncateGeneration
|
|
@@ -1947,28 +2716,63 @@ class PersistedCollectionRuntime<
|
|
|
1947
2716
|
this.activeSubsets.set(subsetKey, options)
|
|
1948
2717
|
const appliedCursor = this.appliedReceiptSequence
|
|
1949
2718
|
try {
|
|
1950
|
-
await this.applyMutex.run(
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
{
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2719
|
+
const hydrated = await this.applyMutex.run(
|
|
2720
|
+
async (): Promise<boolean | `aborted`> => {
|
|
2721
|
+
if (options.signal?.aborted) return `aborted`
|
|
2722
|
+
try {
|
|
2723
|
+
return await this.runInHydrationScope<boolean | `aborted`>(
|
|
2724
|
+
(adapter) => {
|
|
2725
|
+
if (options.signal?.aborted) {
|
|
2726
|
+
return Promise.resolve(`aborted`)
|
|
2727
|
+
}
|
|
2728
|
+
onHydrationStarted?.()
|
|
2729
|
+
return this.hydrateSubsetUnsafe(
|
|
2730
|
+
options,
|
|
2731
|
+
{
|
|
2732
|
+
lifecycleGeneration,
|
|
2733
|
+
requestLocalLoadFailure: true,
|
|
2734
|
+
rejectBufferedReplayFailure: true,
|
|
2735
|
+
},
|
|
2736
|
+
adapter,
|
|
2737
|
+
)
|
|
1959
2738
|
},
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
await this.flushQueuedTxCommittedUnsafe()
|
|
2739
|
+
)
|
|
2740
|
+
} finally {
|
|
2741
|
+
if (
|
|
2742
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
2743
|
+
!this.getCurrentTerminalFailure()
|
|
2744
|
+
) {
|
|
2745
|
+
await this.flushQueuedTxCommittedUnsafe()
|
|
2746
|
+
}
|
|
1969
2747
|
}
|
|
2748
|
+
},
|
|
2749
|
+
)
|
|
2750
|
+
if (hydrated === `aborted`) {
|
|
2751
|
+
if (this.activeSubsets.get(subsetKey) === options) {
|
|
2752
|
+
this.activeSubsets.delete(subsetKey)
|
|
1970
2753
|
}
|
|
1971
|
-
|
|
2754
|
+
return
|
|
2755
|
+
}
|
|
2756
|
+
if (!hydrated) {
|
|
2757
|
+
// The old cache read is discarded. Recovery reacquires this retained
|
|
2758
|
+
// demand under the new storage ID, so await that acquisition rather
|
|
2759
|
+
// than starting another source request with old request data.
|
|
2760
|
+
await this.ensureCacheClaimActive()
|
|
2761
|
+
const rotatedOptions = this.rotatedAcquisitions.get(options)
|
|
2762
|
+
if (
|
|
2763
|
+
rotatedOptions &&
|
|
2764
|
+
this.activeSubsets.get(subsetKey) === options &&
|
|
2765
|
+
!options.signal?.aborted
|
|
2766
|
+
) {
|
|
2767
|
+
await (this.rotatedEnsures.get(options) ??
|
|
2768
|
+
this.persistence.coordinator.requestEnsureRemoteSubset(
|
|
2769
|
+
this.storageCollectionId,
|
|
2770
|
+
rotatedOptions,
|
|
2771
|
+
))
|
|
2772
|
+
await this.applyLeaderCommitsBeforeReply(options)
|
|
2773
|
+
}
|
|
2774
|
+
return
|
|
2775
|
+
}
|
|
1972
2776
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
1973
2777
|
await this.waitForAppliedReceiptsAfter(appliedCursor)
|
|
1974
2778
|
// Leadership can change while hydration waits. Validate newly remote
|
|
@@ -1990,14 +2794,20 @@ class PersistedCollectionRuntime<
|
|
|
1990
2794
|
options.signal?.aborted ||
|
|
1991
2795
|
this.activeSubsets.get(this.getSubsetKey(options)) !== options
|
|
1992
2796
|
) {
|
|
2797
|
+
if (
|
|
2798
|
+
options.signal?.aborted &&
|
|
2799
|
+
this.activeSubsets.get(subsetKey) === options
|
|
2800
|
+
) {
|
|
2801
|
+
this.activeSubsets.delete(subsetKey)
|
|
2802
|
+
}
|
|
1993
2803
|
return
|
|
1994
2804
|
}
|
|
1995
2805
|
|
|
1996
2806
|
if (this.canRouteRemoteDemandThroughCoordinator()) {
|
|
1997
2807
|
try {
|
|
1998
2808
|
await this.persistence.coordinator.requestEnsureRemoteSubset(
|
|
1999
|
-
this.
|
|
2000
|
-
options,
|
|
2809
|
+
this.storageCollectionId,
|
|
2810
|
+
this.rotatedAcquisitions.get(options) ?? options,
|
|
2001
2811
|
)
|
|
2002
2812
|
} catch (error) {
|
|
2003
2813
|
if (
|
|
@@ -2013,6 +2823,7 @@ class PersistedCollectionRuntime<
|
|
|
2013
2823
|
this.queueRemoteSubsetEnsure(options)
|
|
2014
2824
|
throw error
|
|
2015
2825
|
}
|
|
2826
|
+
await this.applyLeaderCommitsBeforeReply(options)
|
|
2016
2827
|
return
|
|
2017
2828
|
}
|
|
2018
2829
|
|
|
@@ -2040,6 +2851,7 @@ class PersistedCollectionRuntime<
|
|
|
2040
2851
|
const failure = this.getCurrentTerminalFailure()
|
|
2041
2852
|
if (failure) return Promise.reject(failure.error)
|
|
2042
2853
|
if (
|
|
2854
|
+
this.hasExpiredCacheClaim() ||
|
|
2043
2855
|
!this.startupSettled ||
|
|
2044
2856
|
this.isHydratingNow() ||
|
|
2045
2857
|
this.canRouteRemoteDemandThroughCoordinator() ||
|
|
@@ -2102,6 +2914,8 @@ class PersistedCollectionRuntime<
|
|
|
2102
2914
|
const subsetKey = this.getSubsetKey(options)
|
|
2103
2915
|
this.activeSubsets.delete(subsetKey)
|
|
2104
2916
|
this.pendingRemoteSubsetEnsures.delete(subsetKey)
|
|
2917
|
+
const remoteOptions = this.rotatedAcquisitions.get(options) ?? options
|
|
2918
|
+
this.rotatedAcquisitions.delete(options)
|
|
2105
2919
|
const demandKey = getLoadSubsetDemandKey(options)
|
|
2106
2920
|
const stillActive = Array.from(this.activeSubsets.values()).some(
|
|
2107
2921
|
(active) => getLoadSubsetDemandKey(active) === demandKey,
|
|
@@ -2109,7 +2923,7 @@ class PersistedCollectionRuntime<
|
|
|
2109
2923
|
if (!stillActive) this.hydratedDemands.delete(demandKey)
|
|
2110
2924
|
if (this.mode === `sync-present`) {
|
|
2111
2925
|
void this.persistence.coordinator
|
|
2112
|
-
.requestReleaseRemoteSubset(this.
|
|
2926
|
+
.requestReleaseRemoteSubset(this.storageCollectionId, remoteOptions)
|
|
2113
2927
|
.catch((error) => {
|
|
2114
2928
|
this.reportSyncError(error)
|
|
2115
2929
|
})
|
|
@@ -2132,10 +2946,11 @@ class PersistedCollectionRuntime<
|
|
|
2132
2946
|
|
|
2133
2947
|
async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
|
|
2134
2948
|
this.throwIfTerminal()
|
|
2949
|
+
await this.ensureCacheClaimActive()
|
|
2135
2950
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
2136
2951
|
// A one-shot refresh does not acquire an enduring subscription lease.
|
|
2137
|
-
await this.applyMutex.run(async () => {
|
|
2138
|
-
await this.runInHydrationScope((adapter) =>
|
|
2952
|
+
const hydrated = await this.applyMutex.run(async () => {
|
|
2953
|
+
const result = await this.runInHydrationScope((adapter) =>
|
|
2139
2954
|
this.hydrateSubsetUnsafe(
|
|
2140
2955
|
options,
|
|
2141
2956
|
{
|
|
@@ -2148,7 +2963,9 @@ class PersistedCollectionRuntime<
|
|
|
2148
2963
|
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
2149
2964
|
await this.flushQueuedTxCommittedUnsafe()
|
|
2150
2965
|
}
|
|
2966
|
+
return result
|
|
2151
2967
|
})
|
|
2968
|
+
if (!hydrated) await this.ensureCacheClaimActive()
|
|
2152
2969
|
}
|
|
2153
2970
|
|
|
2154
2971
|
queueHydrationBufferedTransaction(
|
|
@@ -2159,6 +2976,7 @@ class PersistedCollectionRuntime<
|
|
|
2159
2976
|
transaction.rejectApplied?.(failure.error)
|
|
2160
2977
|
return
|
|
2161
2978
|
}
|
|
2979
|
+
transaction.admittedStorageCollectionId = this.storageCollectionId
|
|
2162
2980
|
this.queuedHydrationTransactions.push(transaction)
|
|
2163
2981
|
}
|
|
2164
2982
|
|
|
@@ -2167,6 +2985,7 @@ class PersistedCollectionRuntime<
|
|
|
2167
2985
|
): Promise<void> {
|
|
2168
2986
|
const failure = this.getCurrentTerminalFailure()
|
|
2169
2987
|
if (failure) return Promise.reject(failure.error)
|
|
2988
|
+
transaction.admittedStorageCollectionId = this.storageCollectionId
|
|
2170
2989
|
return this.applyMutex.run(async () => {
|
|
2171
2990
|
await this.applyBufferedSyncTransactionUnsafe(transaction)
|
|
2172
2991
|
})
|
|
@@ -2299,7 +3118,10 @@ class PersistedCollectionRuntime<
|
|
|
2299
3118
|
attempt(() => {
|
|
2300
3119
|
remoteReleases.push(
|
|
2301
3120
|
this.persistence.coordinator
|
|
2302
|
-
.requestReleaseRemoteSubset(
|
|
3121
|
+
.requestReleaseRemoteSubset(
|
|
3122
|
+
this.storageCollectionId,
|
|
3123
|
+
this.rotatedAcquisitions.get(options) ?? options,
|
|
3124
|
+
)
|
|
2303
3125
|
.catch((error) => {
|
|
2304
3126
|
this.reportSyncError(error)
|
|
2305
3127
|
}),
|
|
@@ -2313,6 +3135,7 @@ class PersistedCollectionRuntime<
|
|
|
2313
3135
|
|
|
2314
3136
|
attempt(() => this.remoteSubsetOwnerUnsubscribe?.())
|
|
2315
3137
|
this.remoteSubsetOwnerUnsubscribe = null
|
|
3138
|
+
this.remoteSubsetOwner = undefined
|
|
2316
3139
|
|
|
2317
3140
|
attempt(() => this.indexAddedUnsubscribe?.())
|
|
2318
3141
|
this.indexAddedUnsubscribe = null
|
|
@@ -2326,6 +3149,16 @@ class PersistedCollectionRuntime<
|
|
|
2326
3149
|
}
|
|
2327
3150
|
|
|
2328
3151
|
this.pendingRemoteSubsetEnsures.clear()
|
|
3152
|
+
this.rotatedAcquisitions.clear()
|
|
3153
|
+
for (const [options, storageId] of this.scopedRefreshes) {
|
|
3154
|
+
remoteReleases.push(
|
|
3155
|
+
this.persistence.coordinator.requestReleaseRemoteSubset(
|
|
3156
|
+
storageId,
|
|
3157
|
+
options,
|
|
3158
|
+
),
|
|
3159
|
+
)
|
|
3160
|
+
}
|
|
3161
|
+
this.scopedRefreshes.clear()
|
|
2329
3162
|
this.activeSubsets.clear()
|
|
2330
3163
|
this.hydratedDemands.clear()
|
|
2331
3164
|
for (const transaction of this.queuedHydrationTransactions) {
|
|
@@ -2335,6 +3168,15 @@ class PersistedCollectionRuntime<
|
|
|
2335
3168
|
this.queuedTxCommitted.length = 0
|
|
2336
3169
|
this.clearSyncControls()
|
|
2337
3170
|
this.collection = null
|
|
3171
|
+
const claim = this.cacheGenerationClaim
|
|
3172
|
+
this.cacheGenerationClaim = undefined
|
|
3173
|
+
this.clearCacheClaimRenewal()
|
|
3174
|
+
this.cacheClaimRecovery = null
|
|
3175
|
+
if (claim && this.persistence.adapter.releaseCacheGenerationClaim) {
|
|
3176
|
+
remoteReleases.push(
|
|
3177
|
+
this.persistence.adapter.releaseCacheGenerationClaim(claim.claimId),
|
|
3178
|
+
)
|
|
3179
|
+
}
|
|
2338
3180
|
return Promise.all(remoteReleases).then(() => {
|
|
2339
3181
|
if (failures.length === 1) throw failures[0]
|
|
2340
3182
|
if (failures.length > 1) {
|
|
@@ -2347,6 +3189,12 @@ class PersistedCollectionRuntime<
|
|
|
2347
3189
|
|
|
2348
3190
|
private advanceLifecycle(): void {
|
|
2349
3191
|
this.lifecycleGeneration++
|
|
3192
|
+
this.clearCacheClaimRenewal()
|
|
3193
|
+
this.cacheClaimRecovery = null
|
|
3194
|
+
this.scopedRecovery = false
|
|
3195
|
+
this.scopedRecoveryPending = false
|
|
3196
|
+
this.scopedRecoverySequence++
|
|
3197
|
+
this.recoverQueuedAfterLocalLoadFailure = false
|
|
2350
3198
|
this.persistedReadiness?.set({ status: `loading` })
|
|
2351
3199
|
this.startupSettled = false
|
|
2352
3200
|
this.publicRowVersion = 0
|
|
@@ -2385,8 +3233,10 @@ class PersistedCollectionRuntime<
|
|
|
2385
3233
|
options: LoadSubsetOptions,
|
|
2386
3234
|
adapter: HydrationPersistenceAdapter,
|
|
2387
3235
|
): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
|
|
2388
|
-
|
|
3236
|
+
if (this.scopedRecovery) return Promise.resolve([])
|
|
3237
|
+
return adapter.loadSubset(this.storageTargetFor(), options, {
|
|
2389
3238
|
requiredIndexSignatures: this.getRequiredIndexSignatures(),
|
|
3239
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
2390
3240
|
}) as Promise<Array<{ key: TKey; value: T; metadata?: unknown }>>
|
|
2391
3241
|
}
|
|
2392
3242
|
|
|
@@ -2397,17 +3247,36 @@ class PersistedCollectionRuntime<
|
|
|
2397
3247
|
return []
|
|
2398
3248
|
}
|
|
2399
3249
|
|
|
2400
|
-
return this.persistence.adapter.scanRows(
|
|
2401
|
-
this.
|
|
2402
|
-
|
|
2403
|
-
) as Promise<Array<PersistedScannedRow<T, TKey>>>
|
|
3250
|
+
return this.persistence.adapter.scanRows(this.storageTargetFor(), options, {
|
|
3251
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
3252
|
+
}) as Promise<Array<PersistedScannedRow<T, TKey>>>
|
|
2404
3253
|
}
|
|
2405
3254
|
|
|
2406
3255
|
async scanPersistedRows(
|
|
2407
3256
|
options?: PersistedRowScanOptions,
|
|
2408
3257
|
): Promise<Array<PersistedScannedRow<T, TKey>>> {
|
|
2409
|
-
|
|
2410
|
-
|
|
3258
|
+
for (;;) {
|
|
3259
|
+
this.throwIfTerminal()
|
|
3260
|
+
await this.ensureCacheClaimActive()
|
|
3261
|
+
const claimId = this.cacheGenerationClaim?.claimId
|
|
3262
|
+
const storageCollectionId = this.storageCollectionId
|
|
3263
|
+
const rows = await this.applyMutex.run(() =>
|
|
3264
|
+
this.scanPersistedRowsUnsafe(options),
|
|
3265
|
+
)
|
|
3266
|
+
if (this.scopedRecoveryPending) {
|
|
3267
|
+
throw new SyncTransactionAbortedError()
|
|
3268
|
+
}
|
|
3269
|
+
if (
|
|
3270
|
+
claimId !== this.cacheGenerationClaim?.claimId ||
|
|
3271
|
+
storageCollectionId !== this.storageCollectionId
|
|
3272
|
+
) {
|
|
3273
|
+
continue
|
|
3274
|
+
}
|
|
3275
|
+
if (claimId && !(await this.renewCacheClaim(this.persistence.adapter))) {
|
|
3276
|
+
continue
|
|
3277
|
+
}
|
|
3278
|
+
return rows
|
|
3279
|
+
}
|
|
2411
3280
|
}
|
|
2412
3281
|
|
|
2413
3282
|
private async hydrateSubsetUnsafe(
|
|
@@ -2419,7 +3288,7 @@ class PersistedCollectionRuntime<
|
|
|
2419
3288
|
rejectBufferedReplayFailure?: boolean
|
|
2420
3289
|
},
|
|
2421
3290
|
adapter: HydrationPersistenceAdapter,
|
|
2422
|
-
): Promise<
|
|
3291
|
+
): Promise<boolean> {
|
|
2423
3292
|
let rowsLoaded = false
|
|
2424
3293
|
let boundRowVersion: number | undefined
|
|
2425
3294
|
let replayFailure: { reason: unknown } | undefined
|
|
@@ -2432,26 +3301,53 @@ class PersistedCollectionRuntime<
|
|
|
2432
3301
|
this.hydratingGeneration = config.lifecycleGeneration
|
|
2433
3302
|
try {
|
|
2434
3303
|
let rows: Array<{ key: TKey; value: T; metadata?: unknown }>
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
3304
|
+
let bindResumeSnapshot: (() => void) | undefined
|
|
3305
|
+
if (config.bindKeySetEvidence && !this.scopedRecovery) {
|
|
3306
|
+
const snapshot = await adapter.loadResumeSnapshot(
|
|
3307
|
+
this.storageTargetFor(),
|
|
3308
|
+
{
|
|
3309
|
+
requiredIndexSignatures: this.getRequiredIndexSignatures(),
|
|
3310
|
+
includeRows: true,
|
|
3311
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
3312
|
+
},
|
|
3313
|
+
)
|
|
2440
3314
|
rows = snapshot.rows as Array<{
|
|
2441
3315
|
key: TKey
|
|
2442
3316
|
value: T
|
|
2443
3317
|
metadata?: unknown
|
|
2444
3318
|
}>
|
|
2445
|
-
if (config.lifecycleGeneration !== this.lifecycleGeneration)
|
|
2446
|
-
|
|
2447
|
-
this.
|
|
3319
|
+
if (config.lifecycleGeneration !== this.lifecycleGeneration)
|
|
3320
|
+
return true
|
|
3321
|
+
if (resetSequence !== this.resetSequence) return true
|
|
3322
|
+
bindResumeSnapshot = () => this.bindResumeSnapshotEvidence(snapshot)
|
|
2448
3323
|
boundRowVersion = snapshot.latestRowVersion
|
|
2449
3324
|
} else {
|
|
2450
3325
|
rows = await this.loadSubsetRowsUnsafe(options, adapter)
|
|
2451
3326
|
}
|
|
3327
|
+
const complete =
|
|
3328
|
+
this.syncMode === `on-demand` &&
|
|
3329
|
+
!this.scopedRecovery &&
|
|
3330
|
+
options.where === undefined &&
|
|
3331
|
+
options.limit === undefined &&
|
|
3332
|
+
options.offset === undefined &&
|
|
3333
|
+
options.cursor === undefined
|
|
3334
|
+
const validationRows = complete ? rows : undefined
|
|
2452
3335
|
rowsLoaded = true
|
|
2453
|
-
if (config.lifecycleGeneration !== this.lifecycleGeneration) return
|
|
2454
|
-
if (resetSequence !== this.resetSequence) return
|
|
3336
|
+
if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
|
|
3337
|
+
if (resetSequence !== this.resetSequence) return true
|
|
3338
|
+
|
|
3339
|
+
// SQLite validated the claim at read admission. A paused sync run
|
|
3340
|
+
// can resume after expiry before its renewal timer runs, so cached
|
|
3341
|
+
// rows need fresh authority before they enter the public Collection.
|
|
3342
|
+
if (
|
|
3343
|
+
this.cacheGenerationClaim &&
|
|
3344
|
+
!(await this.renewCacheClaim(adapter))
|
|
3345
|
+
) {
|
|
3346
|
+
return false
|
|
3347
|
+
}
|
|
3348
|
+
if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
|
|
3349
|
+
if (resetSequence !== this.resetSequence) return true
|
|
3350
|
+
bindResumeSnapshot?.()
|
|
2455
3351
|
|
|
2456
3352
|
if (
|
|
2457
3353
|
!config.bindKeySetEvidence ||
|
|
@@ -2460,7 +3356,7 @@ class PersistedCollectionRuntime<
|
|
|
2460
3356
|
for (const row of rows) {
|
|
2461
3357
|
hydrationContext.suppliedRowKeys.add(row.key)
|
|
2462
3358
|
}
|
|
2463
|
-
const applied = this.applyRowsToCollection(rows)
|
|
3359
|
+
const applied = this.applyRowsToCollection(rows, validationRows)
|
|
2464
3360
|
await whenSyncAccepted(applied)
|
|
2465
3361
|
if (boundRowVersion !== undefined) {
|
|
2466
3362
|
this.publicRowVersion = Math.max(
|
|
@@ -2478,11 +3374,29 @@ class PersistedCollectionRuntime<
|
|
|
2478
3374
|
}
|
|
2479
3375
|
}
|
|
2480
3376
|
|
|
2481
|
-
if (config.lifecycleGeneration !== this.lifecycleGeneration) return
|
|
2482
|
-
|
|
2483
|
-
|
|
3377
|
+
if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
|
|
3378
|
+
if (!this.scopedRecoveryPending) {
|
|
3379
|
+
replayFailure =
|
|
3380
|
+
await this.flushQueuedHydrationTransactionsUnsafe(adapter)
|
|
3381
|
+
}
|
|
3382
|
+
if (config.lifecycleGeneration !== this.lifecycleGeneration) return true
|
|
2484
3383
|
} catch (error) {
|
|
3384
|
+
// A read admitted under a live claim may fail at the adapter's later
|
|
3385
|
+
// claim check after this run wakes from expiry. Its old cache has no
|
|
3386
|
+
// authority; the retained demand must reacquire under a new generation.
|
|
3387
|
+
if (
|
|
3388
|
+
config.requestLocalLoadFailure &&
|
|
3389
|
+
!rowsLoaded &&
|
|
3390
|
+
this.cacheGenerationClaim &&
|
|
3391
|
+
this.hasExpiredCacheClaim()
|
|
3392
|
+
) {
|
|
3393
|
+
return false
|
|
3394
|
+
}
|
|
2485
3395
|
if (config.requestLocalLoadFailure && !rowsLoaded) {
|
|
3396
|
+
if (this.scopedRecoveryPending) {
|
|
3397
|
+
this.recoverQueuedAfterLocalLoadFailure = true
|
|
3398
|
+
throw error
|
|
3399
|
+
}
|
|
2486
3400
|
// Keep admitting source work to the hydration queue while recovery
|
|
2487
3401
|
// reconstructs any persisted baseline required by partial updates.
|
|
2488
3402
|
// The failed subset itself owns no rows, so recovery must evaluate
|
|
@@ -2516,6 +3430,7 @@ class PersistedCollectionRuntime<
|
|
|
2516
3430
|
if (replayFailure && config.rejectBufferedReplayFailure) {
|
|
2517
3431
|
throw replayFailure.reason
|
|
2518
3432
|
}
|
|
3433
|
+
return true
|
|
2519
3434
|
}
|
|
2520
3435
|
|
|
2521
3436
|
private async recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
|
|
@@ -2571,24 +3486,26 @@ class PersistedCollectionRuntime<
|
|
|
2571
3486
|
getPresence(key) === `unknown`
|
|
2572
3487
|
) {
|
|
2573
3488
|
if (snapshotRows === undefined) {
|
|
2574
|
-
const snapshot =
|
|
2575
|
-
|
|
2576
|
-
{
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3489
|
+
const snapshot = this.scopedRecovery
|
|
3490
|
+
? undefined
|
|
3491
|
+
: await adapter.loadResumeSnapshot(this.storageTargetFor(), {
|
|
3492
|
+
requiredIndexSignatures:
|
|
3493
|
+
this.getRequiredIndexSignatures(),
|
|
3494
|
+
includeRows: true,
|
|
3495
|
+
cacheGenerationClaimId:
|
|
3496
|
+
this.cacheGenerationClaim?.claimId,
|
|
3497
|
+
})
|
|
2581
3498
|
this.throwIfLifecycleReplaced(lifecycleGeneration)
|
|
2582
3499
|
if (transaction.signal?.aborted) break
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
snapshot.rows as Array<{
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
)
|
|
3500
|
+
const rows =
|
|
3501
|
+
snapshot && !this.scopedRecovery
|
|
3502
|
+
? (snapshot.rows as Array<{
|
|
3503
|
+
key: TKey
|
|
3504
|
+
value: T
|
|
3505
|
+
metadata?: unknown
|
|
3506
|
+
}>)
|
|
3507
|
+
: []
|
|
3508
|
+
snapshotRows = new Map(rows.map((row) => [row.key, row]))
|
|
2592
3509
|
}
|
|
2593
3510
|
const baseline = snapshotRows.get(key)
|
|
2594
3511
|
if (baseline === undefined) {
|
|
@@ -2649,6 +3566,8 @@ class PersistedCollectionRuntime<
|
|
|
2649
3566
|
|
|
2650
3567
|
private applyRowsToCollection(
|
|
2651
3568
|
rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
|
|
3569
|
+
validationRows?: Array<{ key: TKey; value: T; metadata?: unknown }>,
|
|
3570
|
+
collectionMetadata?: Array<{ key: string; value: unknown }>,
|
|
2652
3571
|
): SyncAppliedReceipt {
|
|
2653
3572
|
if (
|
|
2654
3573
|
!this.syncControls.begin ||
|
|
@@ -2661,10 +3580,68 @@ class PersistedCollectionRuntime<
|
|
|
2661
3580
|
return this.withInternalApply(() => {
|
|
2662
3581
|
this.syncControls.begin?.()
|
|
2663
3582
|
|
|
3583
|
+
// A full durable snapshot validates already public keys. Only the
|
|
3584
|
+
// requested subset may add keys to the public Collection.
|
|
3585
|
+
if (validationRows) {
|
|
3586
|
+
const durableRows = new Map(validationRows.map((row) => [row.key, row]))
|
|
3587
|
+
for (const key of this.collection?.base.keys() ?? []) {
|
|
3588
|
+
if (this.collection?._hasHydratedKey(key)) continue
|
|
3589
|
+
const durable = durableRows.get(key)
|
|
3590
|
+
if (!durable) {
|
|
3591
|
+
this.syncControls.write?.({ type: `delete`, key })
|
|
3592
|
+
} else {
|
|
3593
|
+
const valueChanged = !equalPersistedSnapshotValues(
|
|
3594
|
+
this.collection?.base.get(key),
|
|
3595
|
+
durable.value,
|
|
3596
|
+
)
|
|
3597
|
+
const metadataChanged = !equalPersistedSnapshotValues(
|
|
3598
|
+
this.syncControls.metadata?.row.get(key),
|
|
3599
|
+
durable.metadata,
|
|
3600
|
+
)
|
|
3601
|
+
if (
|
|
3602
|
+
valueChanged ||
|
|
3603
|
+
(metadataChanged && durable.metadata !== undefined)
|
|
3604
|
+
) {
|
|
3605
|
+
// A persisted read supplies a complete row. In partial-update
|
|
3606
|
+
// mode, deleting it within this sync transaction removes fields
|
|
3607
|
+
// that are absent from the durable value before replacement.
|
|
3608
|
+
if (valueChanged) {
|
|
3609
|
+
this.syncControls.write?.({ type: `delete`, key })
|
|
3610
|
+
}
|
|
3611
|
+
this.syncControls.write?.({
|
|
3612
|
+
type: `update`,
|
|
3613
|
+
value: durable.value,
|
|
3614
|
+
metadata: durable.metadata as
|
|
3615
|
+
Record<string, unknown> | undefined,
|
|
3616
|
+
})
|
|
3617
|
+
}
|
|
3618
|
+
if (metadataChanged && durable.metadata === undefined) {
|
|
3619
|
+
this.syncControls.metadata?.row.delete(key)
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
for (const key of this.collection?._syncedRowMetadataKeysOutsideBase() ??
|
|
3624
|
+
[]) {
|
|
3625
|
+
this.syncControls.metadata?.row.delete(key)
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
2664
3629
|
for (const row of rows) {
|
|
2665
|
-
if (
|
|
3630
|
+
if (
|
|
3631
|
+
(validationRows && this.collection?.base.has(row.key)) ||
|
|
3632
|
+
this.collection?._hasHydratedKey(row.key)
|
|
3633
|
+
) {
|
|
2666
3634
|
continue
|
|
2667
3635
|
}
|
|
3636
|
+
if (
|
|
3637
|
+
this.collection?.base.has(row.key) &&
|
|
3638
|
+
!equalPersistedSnapshotValues(
|
|
3639
|
+
this.collection.base.get(row.key),
|
|
3640
|
+
row.value,
|
|
3641
|
+
)
|
|
3642
|
+
) {
|
|
3643
|
+
this.syncControls.write?.({ type: `delete`, key: row.key })
|
|
3644
|
+
}
|
|
2668
3645
|
this.syncControls.write?.({
|
|
2669
3646
|
type: `update`,
|
|
2670
3647
|
value: row.value,
|
|
@@ -2672,6 +3649,9 @@ class PersistedCollectionRuntime<
|
|
|
2672
3649
|
})
|
|
2673
3650
|
}
|
|
2674
3651
|
|
|
3652
|
+
if (collectionMetadata)
|
|
3653
|
+
this.applyCollectionMetadataSnapshot(collectionMetadata)
|
|
3654
|
+
|
|
2675
3655
|
return this.syncControls.commit?.() ?? true
|
|
2676
3656
|
})
|
|
2677
3657
|
}
|
|
@@ -2743,6 +3723,7 @@ class PersistedCollectionRuntime<
|
|
|
2743
3723
|
private replaceCollectionSnapshot(
|
|
2744
3724
|
rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
|
|
2745
3725
|
collectionMetadata: Array<{ key: string; value: unknown }>,
|
|
3726
|
+
replayDemand: boolean,
|
|
2746
3727
|
): SyncAppliedReceipt {
|
|
2747
3728
|
if (
|
|
2748
3729
|
!this.syncControls.begin ||
|
|
@@ -2753,18 +3734,45 @@ class PersistedCollectionRuntime<
|
|
|
2753
3734
|
return true
|
|
2754
3735
|
}
|
|
2755
3736
|
|
|
2756
|
-
const
|
|
2757
|
-
|
|
3737
|
+
const base = this.collection?.base
|
|
3738
|
+
const hydratedKeys = new Set(
|
|
3739
|
+
Array.from(base?.keys() ?? []).filter(
|
|
3740
|
+
(key) => this.collection?._hasHydratedKey(key) === true,
|
|
3741
|
+
),
|
|
2758
3742
|
)
|
|
2759
|
-
const
|
|
2760
|
-
|
|
2761
|
-
|
|
3743
|
+
const rowKeys = new Set(rows.map((row) => row.key))
|
|
3744
|
+
// A truncate replays the Collection's demand. A follower routes that
|
|
3745
|
+
// demand to its leader, whose source loads and commits again, so a
|
|
3746
|
+
// follower replaces its rows in place, except after a reset, which owes
|
|
3747
|
+
// the replay.
|
|
3748
|
+
const followsLeader =
|
|
3749
|
+
this.canRouteRemoteDemandThroughCoordinator() &&
|
|
3750
|
+
!this.persistence.coordinator.isLeader(this.storageCollectionId)
|
|
3751
|
+
const truncate = hydratedKeys.size === 0 && (replayDemand || !followsLeader)
|
|
2762
3752
|
|
|
2763
3753
|
return this.withInternalApply(() => {
|
|
2764
3754
|
this.syncControls.begin?.()
|
|
2765
|
-
|
|
3755
|
+
if (truncate) {
|
|
3756
|
+
this.syncControls.truncate?.()
|
|
3757
|
+
} else {
|
|
3758
|
+
for (const key of base?.keys() ?? []) {
|
|
3759
|
+
if (!hydratedKeys.has(key) && !rowKeys.has(key)) {
|
|
3760
|
+
this.syncControls.write?.({ type: `delete`, key })
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
for (const key of this.collection?._syncedRowMetadataKeysOutsideBase() ??
|
|
3764
|
+
[]) {
|
|
3765
|
+
this.syncControls.metadata?.row.delete(key)
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
2766
3768
|
|
|
2767
3769
|
for (const row of rows) {
|
|
3770
|
+
if (hydratedKeys.has(row.key)) continue
|
|
3771
|
+
if (!truncate && base?.has(row.key)) {
|
|
3772
|
+
// Remove stale fields before applying a complete persisted row in
|
|
3773
|
+
// the default partial-update mode, without truncating seed keys.
|
|
3774
|
+
this.syncControls.write?.({ type: `delete`, key: row.key })
|
|
3775
|
+
}
|
|
2768
3776
|
this.syncControls.write?.({
|
|
2769
3777
|
type: `update`,
|
|
2770
3778
|
value: row.value,
|
|
@@ -2772,16 +3780,7 @@ class PersistedCollectionRuntime<
|
|
|
2772
3780
|
})
|
|
2773
3781
|
}
|
|
2774
3782
|
|
|
2775
|
-
|
|
2776
|
-
if (!nextMetadata.has(key)) {
|
|
2777
|
-
this.syncControls.metadata?.collection.delete(key)
|
|
2778
|
-
}
|
|
2779
|
-
})
|
|
2780
|
-
|
|
2781
|
-
nextMetadata.forEach((value, key) => {
|
|
2782
|
-
this.syncControls.metadata?.collection.set(key, value)
|
|
2783
|
-
})
|
|
2784
|
-
|
|
3783
|
+
this.applyCollectionMetadataSnapshot(collectionMetadata)
|
|
2785
3784
|
return this.syncControls.commit?.() ?? true
|
|
2786
3785
|
})
|
|
2787
3786
|
}
|
|
@@ -2853,6 +3852,42 @@ class PersistedCollectionRuntime<
|
|
|
2853
3852
|
}> {
|
|
2854
3853
|
this.throwIfTerminal()
|
|
2855
3854
|
this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
|
|
3855
|
+
if (
|
|
3856
|
+
transaction.admittedStorageCollectionId !== undefined &&
|
|
3857
|
+
transaction.admittedStorageCollectionId !== this.storageCollectionId
|
|
3858
|
+
) {
|
|
3859
|
+
const error = new SyncTransactionAbortedError()
|
|
3860
|
+
if (!transaction.rejectApplied) throw error
|
|
3861
|
+
transaction.rejectApplied(error)
|
|
3862
|
+
return { applied: false }
|
|
3863
|
+
}
|
|
3864
|
+
if (!transaction.internal && this.scopedRecoveryPending) {
|
|
3865
|
+
const error = new SyncTransactionAbortedError()
|
|
3866
|
+
if (!transaction.rejectApplied) throw error
|
|
3867
|
+
transaction.rejectApplied(error)
|
|
3868
|
+
return { applied: false }
|
|
3869
|
+
}
|
|
3870
|
+
if (
|
|
3871
|
+
!transaction.internal &&
|
|
3872
|
+
this.cacheGenerationClaim &&
|
|
3873
|
+
(scopedAdapter ?? this.persistence.adapter).renewCacheGenerationClaim &&
|
|
3874
|
+
!(await this.renewCacheClaim(scopedAdapter ?? this.persistence.adapter))
|
|
3875
|
+
) {
|
|
3876
|
+
const error = new SyncTransactionAbortedError()
|
|
3877
|
+
// Recovery needs this apply mutex, so start it after the rejected
|
|
3878
|
+
// transaction has released its turn. Its new source snapshots use a
|
|
3879
|
+
// fresh cache generation; this commit must never be replayed into it.
|
|
3880
|
+
queueMicrotask(() => {
|
|
3881
|
+
if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3882
|
+
if (this.scopedRecoveryPending) return
|
|
3883
|
+
void this.startScopedRecovery().catch((failure) => {
|
|
3884
|
+
this.markTerminalFailure(failure, transaction.lifecycleGeneration)
|
|
3885
|
+
})
|
|
3886
|
+
})
|
|
3887
|
+
if (!transaction.rejectApplied) throw error
|
|
3888
|
+
transaction.rejectApplied(error)
|
|
3889
|
+
return { applied: false }
|
|
3890
|
+
}
|
|
2856
3891
|
const abortedBeforeApplication = transaction.signal?.aborted === true
|
|
2857
3892
|
let abortedDuringApplication = false
|
|
2858
3893
|
let applicationReturned = false
|
|
@@ -2951,10 +3986,11 @@ class PersistedCollectionRuntime<
|
|
|
2951
3986
|
}
|
|
2952
3987
|
const streamPosition = this.nextLocalStreamPosition()
|
|
2953
3988
|
const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
|
|
3989
|
+
const storageCollectionId = this.storageCollectionId
|
|
2954
3990
|
let response: ApplyCommittedTxResponse
|
|
2955
3991
|
try {
|
|
2956
3992
|
response = await this.persistence.coordinator.requestApplyCommittedTx(
|
|
2957
|
-
|
|
3993
|
+
storageCollectionId,
|
|
2958
3994
|
tx,
|
|
2959
3995
|
// A hydration-scoped adapter is already inside the shared scheduler.
|
|
2960
3996
|
// Ordinary source commits enter it before taking the writer lock.
|
|
@@ -2971,7 +4007,7 @@ class PersistedCollectionRuntime<
|
|
|
2971
4007
|
let reconciled: ReconcileCommittedTxResponse
|
|
2972
4008
|
try {
|
|
2973
4009
|
reconciled = await this.persistence.coordinator.reconcileCommittedTx(
|
|
2974
|
-
|
|
4010
|
+
storageCollectionId,
|
|
2975
4011
|
tx,
|
|
2976
4012
|
anchor,
|
|
2977
4013
|
scopedAdapter,
|
|
@@ -3051,7 +4087,10 @@ class PersistedCollectionRuntime<
|
|
|
3051
4087
|
this.throwIfTerminal()
|
|
3052
4088
|
this.throwIfLifecycleReplaced(lifecycleGeneration)
|
|
3053
4089
|
try {
|
|
3054
|
-
await this.persistence.adapter.applyCommittedTx(
|
|
4090
|
+
await this.persistence.adapter.applyCommittedTx(
|
|
4091
|
+
this.storageTargetFor(),
|
|
4092
|
+
tx,
|
|
4093
|
+
)
|
|
3055
4094
|
} catch (error) {
|
|
3056
4095
|
throw this.markTerminalFailure(
|
|
3057
4096
|
toPersistedCollectionDurabilityError(this.collectionId, error),
|
|
@@ -3070,6 +4109,7 @@ class PersistedCollectionRuntime<
|
|
|
3070
4109
|
term: streamPosition.term,
|
|
3071
4110
|
seq: streamPosition.seq,
|
|
3072
4111
|
rowVersion: streamPosition.rowVersion,
|
|
4112
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
3073
4113
|
truncate: transaction.truncate,
|
|
3074
4114
|
mutations: transaction.operations.map((operation) =>
|
|
3075
4115
|
operation.type === `update`
|
|
@@ -3114,6 +4154,7 @@ class PersistedCollectionRuntime<
|
|
|
3114
4154
|
term: streamPosition.term,
|
|
3115
4155
|
seq: streamPosition.seq,
|
|
3116
4156
|
rowVersion: streamPosition.rowVersion,
|
|
4157
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
3117
4158
|
mutations: mutations.map((mutation) => {
|
|
3118
4159
|
if (mutation.type === `delete`) {
|
|
3119
4160
|
return {
|
|
@@ -3201,6 +4242,7 @@ class PersistedCollectionRuntime<
|
|
|
3201
4242
|
// but leave the coordinator's state.latestSeq stale, causing seq collisions
|
|
3202
4243
|
// when follower RPCs later arrive.
|
|
3203
4244
|
if (this.persistence.coordinator.requestApplyLocalMutations) {
|
|
4245
|
+
const target = this.storageTargetFor()
|
|
3204
4246
|
const envelopeMutations = mutations.map((mutation) =>
|
|
3205
4247
|
toPersistedMutationEnvelope(
|
|
3206
4248
|
mutation as unknown as PendingMutation<Record<string, unknown>>,
|
|
@@ -3209,8 +4251,9 @@ class PersistedCollectionRuntime<
|
|
|
3209
4251
|
|
|
3210
4252
|
const response =
|
|
3211
4253
|
await this.persistence.coordinator.requestApplyLocalMutations(
|
|
3212
|
-
this.
|
|
4254
|
+
this.storageCollectionId,
|
|
3213
4255
|
envelopeMutations,
|
|
4256
|
+
target.kind === `managed` ? target.claimId : undefined,
|
|
3214
4257
|
)
|
|
3215
4258
|
|
|
3216
4259
|
this.throwIfLifecycleReplaced(lifecycleGeneration)
|
|
@@ -3345,12 +4388,12 @@ class PersistedCollectionRuntime<
|
|
|
3345
4388
|
const envelope: ProtocolEnvelope<TxCommitted> = {
|
|
3346
4389
|
v: 1,
|
|
3347
4390
|
dbName: this.dbName,
|
|
3348
|
-
collectionId: this.
|
|
4391
|
+
collectionId: this.storageCollectionId,
|
|
3349
4392
|
senderId: this.persistence.coordinator.getNodeId(),
|
|
3350
4393
|
ts: Date.now(),
|
|
3351
4394
|
payload: txCommitted,
|
|
3352
4395
|
}
|
|
3353
|
-
this.persistence.coordinator.publish(this.
|
|
4396
|
+
this.persistence.coordinator.publish(this.storageCollectionId, envelope)
|
|
3354
4397
|
}
|
|
3355
4398
|
|
|
3356
4399
|
private observeStreamPosition(
|
|
@@ -3420,7 +4463,7 @@ class PersistedCollectionRuntime<
|
|
|
3420
4463
|
// A follower routes demand to the elected owner even when its own source
|
|
3421
4464
|
// cannot own acquisitions. Only an elected node needs a local owner.
|
|
3422
4465
|
return (
|
|
3423
|
-
!this.persistence.coordinator.isLeader(this.
|
|
4466
|
+
!this.persistence.coordinator.isLeader(this.storageCollectionId) ||
|
|
3424
4467
|
this.remoteSubsetOwnerUnsubscribe !== null
|
|
3425
4468
|
)
|
|
3426
4469
|
}
|
|
@@ -3490,8 +4533,8 @@ class PersistedCollectionRuntime<
|
|
|
3490
4533
|
}
|
|
3491
4534
|
try {
|
|
3492
4535
|
await this.persistence.coordinator.requestEnsureRemoteSubset(
|
|
3493
|
-
this.
|
|
3494
|
-
options,
|
|
4536
|
+
this.storageCollectionId,
|
|
4537
|
+
this.rotatedAcquisitions.get(options) ?? options,
|
|
3495
4538
|
)
|
|
3496
4539
|
this.pendingRemoteSubsetEnsures.delete(subsetKey)
|
|
3497
4540
|
} catch (error) {
|
|
@@ -3515,11 +4558,12 @@ class PersistedCollectionRuntime<
|
|
|
3515
4558
|
}
|
|
3516
4559
|
|
|
3517
4560
|
this.persistence.coordinator.setAdapterForCollection?.(
|
|
3518
|
-
this.
|
|
4561
|
+
this.storageCollectionId,
|
|
3519
4562
|
this.persistence.adapter,
|
|
4563
|
+
this.cacheGenerationClaim?.claimId,
|
|
3520
4564
|
)
|
|
3521
4565
|
this.coordinatorUnsubscribe = this.persistence.coordinator.subscribe(
|
|
3522
|
-
this.
|
|
4566
|
+
this.storageCollectionId,
|
|
3523
4567
|
(message) => {
|
|
3524
4568
|
this.onCoordinatorMessage(message)
|
|
3525
4569
|
},
|
|
@@ -3527,34 +4571,43 @@ class PersistedCollectionRuntime<
|
|
|
3527
4571
|
}
|
|
3528
4572
|
|
|
3529
4573
|
private onCoordinatorMessage(message: ProtocolEnvelope<unknown>): void {
|
|
3530
|
-
if (message.collectionId !== this.
|
|
4574
|
+
if (message.collectionId !== this.storageCollectionId) {
|
|
3531
4575
|
return
|
|
3532
4576
|
}
|
|
3533
4577
|
if (this.getCurrentTerminalFailure()) {
|
|
3534
4578
|
return
|
|
3535
4579
|
}
|
|
3536
|
-
|
|
3537
4580
|
const { payload } = message
|
|
3538
4581
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
3539
4582
|
const isSelf = message.senderId === this.persistence.coordinator.getNodeId()
|
|
4583
|
+
const claimExpired = this.hasExpiredCacheClaim()
|
|
4584
|
+
if (claimExpired && !isTxCommittedPayload(payload)) {
|
|
4585
|
+
this.scheduleCacheClaimRecovery(lifecycleGeneration)
|
|
4586
|
+
return
|
|
4587
|
+
}
|
|
3540
4588
|
|
|
3541
4589
|
// Allow tx:committed from self — the coordinator produces these on behalf
|
|
3542
4590
|
// of both local and remote mutations. The seq dedup in
|
|
3543
4591
|
// processCommittedTxUnsafe prevents double-processing of our own writes.
|
|
3544
4592
|
if (isTxCommittedPayload(payload)) {
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
4593
|
+
// Commits apply in arrival order through the queue. While the claim is
|
|
4594
|
+
// expired, keep them until renewal or rotation decides this generation;
|
|
4595
|
+
// a renewed generation still owes its rows. A hydration flushes the
|
|
4596
|
+
// queue when it ends.
|
|
4597
|
+
this.queuedTxCommitted.push({
|
|
4598
|
+
txCommitted: payload,
|
|
4599
|
+
lifecycleGeneration,
|
|
4600
|
+
storageCollectionId: message.collectionId,
|
|
4601
|
+
})
|
|
4602
|
+
if (claimExpired) {
|
|
4603
|
+
this.scheduleCacheClaimRecovery(lifecycleGeneration)
|
|
4604
|
+
} else if (!this.isHydratingNow()) {
|
|
4605
|
+
void this.applyMutex
|
|
4606
|
+
.run(() => this.flushQueuedTxCommittedUnsafe())
|
|
4607
|
+
.catch((error) => {
|
|
4608
|
+
this.markTerminalFailure(error, lifecycleGeneration)
|
|
4609
|
+
})
|
|
3551
4610
|
}
|
|
3552
|
-
|
|
3553
|
-
void this.applyMutex
|
|
3554
|
-
.run(() => this.processCommittedTxUnsafe(payload, lifecycleGeneration))
|
|
3555
|
-
.catch((error) => {
|
|
3556
|
-
this.markTerminalFailure(error, lifecycleGeneration)
|
|
3557
|
-
})
|
|
3558
4611
|
return
|
|
3559
4612
|
}
|
|
3560
4613
|
|
|
@@ -3566,6 +4619,7 @@ class PersistedCollectionRuntime<
|
|
|
3566
4619
|
if (isCollectionResetPayload(payload)) {
|
|
3567
4620
|
void this.applyMutex
|
|
3568
4621
|
.run(async () => {
|
|
4622
|
+
if (message.collectionId !== this.storageCollectionId) return
|
|
3569
4623
|
await this.runInHydrationScope(async (adapter) => {
|
|
3570
4624
|
await this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
|
|
3571
4625
|
await this.rebaseDurableStreamFromSnapshotUnsafe(
|
|
@@ -3591,14 +4645,26 @@ class PersistedCollectionRuntime<
|
|
|
3591
4645
|
|
|
3592
4646
|
private async flushQueuedTxCommittedUnsafe(): Promise<void> {
|
|
3593
4647
|
while (this.queuedTxCommitted.length > 0) {
|
|
4648
|
+
// Claim recovery flushes the queue once the generation is decided.
|
|
4649
|
+
if (this.hasExpiredCacheClaim()) {
|
|
4650
|
+
this.scheduleCacheClaimRecovery(this.lifecycleGeneration)
|
|
4651
|
+
return
|
|
4652
|
+
}
|
|
3594
4653
|
const queued = this.queuedTxCommitted.shift()
|
|
3595
4654
|
if (!queued) {
|
|
3596
4655
|
continue
|
|
3597
4656
|
}
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
4657
|
+
if (queued.storageCollectionId !== this.storageCollectionId) continue
|
|
4658
|
+
// A queued commit has left the queue, so a failure must be terminal,
|
|
4659
|
+
// as it is for a commit applied when it arrives.
|
|
4660
|
+
try {
|
|
4661
|
+
await this.processCommittedTxUnsafe(
|
|
4662
|
+
queued.txCommitted,
|
|
4663
|
+
queued.lifecycleGeneration,
|
|
4664
|
+
)
|
|
4665
|
+
} catch (error) {
|
|
4666
|
+
throw this.markTerminalFailure(error, queued.lifecycleGeneration)
|
|
4667
|
+
}
|
|
3602
4668
|
}
|
|
3603
4669
|
}
|
|
3604
4670
|
|
|
@@ -3656,7 +4722,7 @@ class PersistedCollectionRuntime<
|
|
|
3656
4722
|
hasGapAcrossTerms
|
|
3657
4723
|
|
|
3658
4724
|
if (hasGap) {
|
|
3659
|
-
await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
|
|
4725
|
+
if (!(await this.recoverFromSeqGapUnsafe(lifecycleGeneration))) return
|
|
3660
4726
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3661
4727
|
if (txCommitted.latestRowVersion <= this.publicRowVersion) return
|
|
3662
4728
|
}
|
|
@@ -3685,8 +4751,9 @@ class PersistedCollectionRuntime<
|
|
|
3685
4751
|
lifecycleGeneration: number,
|
|
3686
4752
|
): Promise<void> {
|
|
3687
4753
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3688
|
-
const snapshot = await adapter.loadResumeSnapshot(this.
|
|
4754
|
+
const snapshot = await adapter.loadResumeSnapshot(this.storageTargetFor(), {
|
|
3689
4755
|
includeRows: false,
|
|
4756
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
3690
4757
|
})
|
|
3691
4758
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3692
4759
|
// The reset epoch can restart row versions at zero. Keep the term/seq
|
|
@@ -3702,21 +4769,35 @@ class PersistedCollectionRuntime<
|
|
|
3702
4769
|
|
|
3703
4770
|
private async recoverFromSeqGapUnsafe(
|
|
3704
4771
|
lifecycleGeneration: number,
|
|
3705
|
-
): Promise<
|
|
3706
|
-
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
4772
|
+
): Promise<boolean> {
|
|
4773
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return false
|
|
3707
4774
|
if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
|
|
3708
4775
|
let pullResponse: PullSinceResponse | undefined
|
|
3709
4776
|
try {
|
|
3710
4777
|
pullResponse = await this.persistence.coordinator.pullSince(
|
|
3711
|
-
this.
|
|
4778
|
+
this.storageCollectionId,
|
|
3712
4779
|
this.publicRowVersion,
|
|
4780
|
+
undefined,
|
|
4781
|
+
this.cacheGenerationClaim?.claimId,
|
|
3713
4782
|
)
|
|
3714
4783
|
} catch (error) {
|
|
3715
4784
|
console.warn(`Failed pullSince recovery attempt:`, error)
|
|
3716
4785
|
}
|
|
3717
4786
|
|
|
4787
|
+
// The replay read may have started under a live claim and returned
|
|
4788
|
+
// after expiry. Recovery needs the apply mutex held by this callback,
|
|
4789
|
+
// so schedule it after discarding the old response.
|
|
4790
|
+
if (this.scopedRecoveryPending) return false
|
|
4791
|
+
if (
|
|
4792
|
+
this.cacheGenerationClaim &&
|
|
4793
|
+
!(await this.renewCacheClaim(this.persistence.adapter))
|
|
4794
|
+
) {
|
|
4795
|
+
this.scheduleCacheClaimRecovery(lifecycleGeneration)
|
|
4796
|
+
return false
|
|
4797
|
+
}
|
|
4798
|
+
|
|
3718
4799
|
if (pullResponse) {
|
|
3719
|
-
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
4800
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return false
|
|
3720
4801
|
|
|
3721
4802
|
if (pullResponse.ok) {
|
|
3722
4803
|
this.observeStreamPosition(
|
|
@@ -3732,7 +4813,7 @@ class PersistedCollectionRuntime<
|
|
|
3732
4813
|
this.publicRowVersion,
|
|
3733
4814
|
pullResponse.latestRowVersion,
|
|
3734
4815
|
)
|
|
3735
|
-
return
|
|
4816
|
+
return true
|
|
3736
4817
|
}
|
|
3737
4818
|
const deltas = pullResponse.deltas
|
|
3738
4819
|
if (!deltas) {
|
|
@@ -3743,7 +4824,7 @@ class PersistedCollectionRuntime<
|
|
|
3743
4824
|
this.publicRowVersion,
|
|
3744
4825
|
pullResponse.latestRowVersion,
|
|
3745
4826
|
)
|
|
3746
|
-
return
|
|
4827
|
+
return true
|
|
3747
4828
|
}
|
|
3748
4829
|
|
|
3749
4830
|
await this.runInHydrationScope(async (adapter) => {
|
|
@@ -3772,12 +4853,12 @@ class PersistedCollectionRuntime<
|
|
|
3772
4853
|
this.publicRowVersion,
|
|
3773
4854
|
pullResponse.latestRowVersion,
|
|
3774
4855
|
)
|
|
3775
|
-
return
|
|
4856
|
+
return true
|
|
3776
4857
|
}
|
|
3777
4858
|
}
|
|
3778
4859
|
}
|
|
3779
4860
|
|
|
3780
|
-
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
4861
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return false
|
|
3781
4862
|
// A gap is not a schema reset. Replace the durable snapshot in one
|
|
3782
4863
|
// publication so readers never see an empty intermediate Collection.
|
|
3783
4864
|
await this.runInHydrationScope((adapter) =>
|
|
@@ -3793,6 +4874,53 @@ class PersistedCollectionRuntime<
|
|
|
3793
4874
|
this.queueRemoteSubsetEnsure(options)
|
|
3794
4875
|
}
|
|
3795
4876
|
}
|
|
4877
|
+
return true
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
private refreshScopedSubsets(): void {
|
|
4881
|
+
if (!this.remoteSubsetOwner) return
|
|
4882
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
4883
|
+
const storageId = this.storageCollectionId
|
|
4884
|
+
for (const [subsetKey, options] of this.activeSubsets) {
|
|
4885
|
+
if (options.signal?.aborted) continue
|
|
4886
|
+
this.hydratedDemands.delete(getLoadSubsetDemandKey(options))
|
|
4887
|
+
// A scoped cache cannot answer the invalidation. This one-shot
|
|
4888
|
+
// acquisition requests source evidence while the original demand keeps
|
|
4889
|
+
// its lease; releasing it after settlement preserves exact ownership.
|
|
4890
|
+
const refreshOptions: LoadSubsetOptions = { ...options, refetch: true }
|
|
4891
|
+
this.scopedRefreshes.set(refreshOptions, storageId)
|
|
4892
|
+
void Promise.resolve()
|
|
4893
|
+
.then(() => {
|
|
4894
|
+
if (
|
|
4895
|
+
lifecycleGeneration !== this.lifecycleGeneration ||
|
|
4896
|
+
this.activeSubsets.get(subsetKey) !== options
|
|
4897
|
+
) {
|
|
4898
|
+
return
|
|
4899
|
+
}
|
|
4900
|
+
return this.persistence.coordinator.requestEnsureRemoteSubset(
|
|
4901
|
+
storageId,
|
|
4902
|
+
refreshOptions,
|
|
4903
|
+
)
|
|
4904
|
+
})
|
|
4905
|
+
.catch((error: unknown) => {
|
|
4906
|
+
if (
|
|
4907
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
4908
|
+
!options.signal?.aborted
|
|
4909
|
+
) {
|
|
4910
|
+
this.reportSyncError(error)
|
|
4911
|
+
}
|
|
4912
|
+
})
|
|
4913
|
+
.finally(() => {
|
|
4914
|
+
if (!this.scopedRefreshes.delete(refreshOptions)) return
|
|
4915
|
+
void this.persistence.coordinator
|
|
4916
|
+
.requestReleaseRemoteSubset(storageId, refreshOptions)
|
|
4917
|
+
.catch((error: unknown) => {
|
|
4918
|
+
if (lifecycleGeneration === this.lifecycleGeneration) {
|
|
4919
|
+
this.reportSyncError(error)
|
|
4920
|
+
}
|
|
4921
|
+
})
|
|
4922
|
+
})
|
|
4923
|
+
}
|
|
3796
4924
|
}
|
|
3797
4925
|
|
|
3798
4926
|
private async truncateAndReloadUnsafe(
|
|
@@ -3800,10 +4928,21 @@ class PersistedCollectionRuntime<
|
|
|
3800
4928
|
lifecycleGeneration = this.lifecycleGeneration,
|
|
3801
4929
|
): Promise<void> {
|
|
3802
4930
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
4931
|
+
if (this.scopedRecovery) {
|
|
4932
|
+
this.refreshScopedSubsets()
|
|
4933
|
+
return
|
|
4934
|
+
}
|
|
3803
4935
|
// A subscriber may reacquire reentrantly from the truncate commit.
|
|
3804
4936
|
this.hydratedDemands.clear()
|
|
3805
4937
|
this.resetSequence++
|
|
3806
|
-
|
|
4938
|
+
// On-demand reset keeps previously public rows and hydration seeds until
|
|
4939
|
+
// the new durable snapshot can replace them in one publication. Active
|
|
4940
|
+
// demand may add rows; retired demand may only validate visible keys.
|
|
4941
|
+
if (
|
|
4942
|
+
this.syncMode === `eager` &&
|
|
4943
|
+
this.syncControls.begin &&
|
|
4944
|
+
this.syncControls.commit
|
|
4945
|
+
) {
|
|
3807
4946
|
const applied = this.withInternalApply(() => {
|
|
3808
4947
|
this.syncControls.begin?.()
|
|
3809
4948
|
this.syncControls.truncate?.()
|
|
@@ -3813,13 +4952,24 @@ class PersistedCollectionRuntime<
|
|
|
3813
4952
|
}
|
|
3814
4953
|
|
|
3815
4954
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3816
|
-
|
|
4955
|
+
// A reset replays demand: the owner's reset rows return only when the
|
|
4956
|
+
// follower requests its demand again.
|
|
4957
|
+
await this.reloadActiveSubsetsUnsafe(
|
|
4958
|
+
adapter,
|
|
4959
|
+
false,
|
|
4960
|
+
this.syncMode === `eager`,
|
|
4961
|
+
true,
|
|
4962
|
+
)
|
|
3817
4963
|
}
|
|
3818
4964
|
|
|
3819
4965
|
private async invalidateFromCommittedTxUnsafe(
|
|
3820
4966
|
txCommitted: TxCommitted,
|
|
3821
4967
|
adapter: HydrationPersistenceAdapter,
|
|
3822
4968
|
): Promise<void> {
|
|
4969
|
+
if (this.scopedRecovery) {
|
|
4970
|
+
this.refreshScopedSubsets()
|
|
4971
|
+
return
|
|
4972
|
+
}
|
|
3823
4973
|
const reloadActiveSubsets = (skipUnchangedReplace = false) =>
|
|
3824
4974
|
this.runInHydrationScope(
|
|
3825
4975
|
(scopedAdapter) =>
|
|
@@ -3843,6 +4993,38 @@ class PersistedCollectionRuntime<
|
|
|
3843
4993
|
(opt) => opt.limit != null || opt.offset != null || opt.cursor != null,
|
|
3844
4994
|
)
|
|
3845
4995
|
|
|
4996
|
+
// A targeted envelope separates changed rows from deleted keys and omits
|
|
4997
|
+
// insert/update identity. A same-key insert replaces the durable row,
|
|
4998
|
+
// while an update merges omitted fields. The public row cannot decide
|
|
4999
|
+
// which operation produced a changed value. A previously unseen key also
|
|
5000
|
+
// has no public baseline with which to complete a source patch. Read the
|
|
5001
|
+
// durable result whenever that ambiguity affects a public row or demand.
|
|
5002
|
+
const changedKeys = new Set(txCommitted.changedRows.map((row) => row.key))
|
|
5003
|
+
const hasRepeatedChange =
|
|
5004
|
+
changedKeys.size !== txCommitted.changedRows.length
|
|
5005
|
+
const hasChangedAndDeletedKey = txCommitted.deletedKeys.some((key) =>
|
|
5006
|
+
changedKeys.has(key),
|
|
5007
|
+
)
|
|
5008
|
+
const hasChangedPublicKey = txCommitted.changedRows.some((row) =>
|
|
5009
|
+
this.collection?.base.has(row.key as TKey),
|
|
5010
|
+
)
|
|
5011
|
+
const hasUnseenDemandedKey =
|
|
5012
|
+
this.activeSubsets.size > 0 &&
|
|
5013
|
+
txCommitted.changedRows.some(
|
|
5014
|
+
(row) =>
|
|
5015
|
+
!this.collection?.base.has(row.key as TKey) &&
|
|
5016
|
+
!this.collection?._hasHydratedKey(row.key as TKey),
|
|
5017
|
+
)
|
|
5018
|
+
if (
|
|
5019
|
+
hasRepeatedChange ||
|
|
5020
|
+
hasChangedAndDeletedKey ||
|
|
5021
|
+
hasChangedPublicKey ||
|
|
5022
|
+
(this.syncMode === `on-demand` && hasUnseenDemandedKey)
|
|
5023
|
+
) {
|
|
5024
|
+
await reloadActiveSubsets()
|
|
5025
|
+
return
|
|
5026
|
+
}
|
|
5027
|
+
|
|
3846
5028
|
if (!hasPaginatedSubset || changedKeyCount === 0) {
|
|
3847
5029
|
await this.applyTargetedInvalidationUnsafe(txCommitted)
|
|
3848
5030
|
return
|
|
@@ -3867,23 +5049,40 @@ class PersistedCollectionRuntime<
|
|
|
3867
5049
|
key: changedKey,
|
|
3868
5050
|
value: newValue,
|
|
3869
5051
|
} of txCommitted.changedRows) {
|
|
3870
|
-
const
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
}
|
|
3878
|
-
|
|
5052
|
+
const key = changedKey as TKey
|
|
5053
|
+
if (this.collection?._hasHydratedKey(key)) continue
|
|
5054
|
+
const previous = this.collection?.base.get(key)
|
|
5055
|
+
if (previous === undefined && this.activeSubsets.size === 0) continue
|
|
5056
|
+
const completeValue = {
|
|
5057
|
+
...(previous as object | undefined),
|
|
5058
|
+
...newValue,
|
|
5059
|
+
} as T
|
|
5060
|
+
const currentlyDemanded = matchesAnySubset(
|
|
5061
|
+
completeValue,
|
|
5062
|
+
subsetEvaluators,
|
|
5063
|
+
)
|
|
5064
|
+
const previouslyOutsideDemand =
|
|
5065
|
+
previous !== undefined &&
|
|
5066
|
+
!matchesAnySubset(previous, subsetEvaluators)
|
|
5067
|
+
|
|
5068
|
+
if (currentlyDemanded || previouslyOutsideDemand) {
|
|
5069
|
+
this.syncControls.write?.({ type: `update`, value: completeValue })
|
|
5070
|
+
} else if (previous !== undefined) {
|
|
5071
|
+
this.syncControls.write?.({ type: `delete`, key })
|
|
3879
5072
|
}
|
|
3880
5073
|
}
|
|
3881
5074
|
|
|
3882
5075
|
for (const deletedKey of txCommitted.deletedKeys) {
|
|
3883
|
-
|
|
5076
|
+
const key = deletedKey as TKey
|
|
5077
|
+
if (!this.collection?._hasHydratedKey(key)) {
|
|
5078
|
+
this.syncControls.write?.({ type: `delete`, key })
|
|
5079
|
+
}
|
|
3884
5080
|
}
|
|
3885
5081
|
|
|
3886
5082
|
txCommitted.rowMetadataMutations?.forEach((mutation) => {
|
|
5083
|
+
// A DbClient hydration seed owns both its row and row metadata until
|
|
5084
|
+
// an accepted source transaction confirms the key.
|
|
5085
|
+
if (this.collection?._hasHydratedKey(mutation.key as TKey)) return
|
|
3887
5086
|
if (mutation.type === `delete`) {
|
|
3888
5087
|
this.syncControls.metadata?.row.delete(mutation.key as TKey)
|
|
3889
5088
|
} else {
|
|
@@ -3913,13 +5112,39 @@ class PersistedCollectionRuntime<
|
|
|
3913
5112
|
private async reloadActiveSubsetsUnsafe(
|
|
3914
5113
|
adapter: HydrationPersistenceAdapter,
|
|
3915
5114
|
skipUnchangedReplace = false,
|
|
5115
|
+
restoreResetBaseline = false,
|
|
5116
|
+
replayDemand = false,
|
|
3916
5117
|
): Promise<void> {
|
|
5118
|
+
if (this.scopedRecovery) {
|
|
5119
|
+
this.refreshScopedSubsets()
|
|
5120
|
+
return
|
|
5121
|
+
}
|
|
3917
5122
|
const lifecycleGeneration = this.lifecycleGeneration
|
|
3918
5123
|
const truncateGeneration = this.sourceTruncateGeneration
|
|
3919
|
-
const activeSubsetOptions =
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
5124
|
+
const activeSubsetOptions = Array.from(this.activeSubsets.values())
|
|
5125
|
+
if (restoreResetBaseline && activeSubsetOptions.length === 0) {
|
|
5126
|
+
activeSubsetOptions.push({})
|
|
5127
|
+
}
|
|
5128
|
+
const evaluators = activeSubsetOptions.map((options) =>
|
|
5129
|
+
options.where ? compileSingleRowExpression(options.where) : null,
|
|
5130
|
+
)
|
|
5131
|
+
// A filtered read has no authority over public rows that were already
|
|
5132
|
+
// outside every active predicate. Validate those keys against the complete
|
|
5133
|
+
// durable snapshot; only active demand may admit previously unseen keys.
|
|
5134
|
+
const outsideDemandKeys = activeSubsetOptions.some(
|
|
5135
|
+
(options) =>
|
|
5136
|
+
options.limit != null ||
|
|
5137
|
+
options.offset != null ||
|
|
5138
|
+
options.cursor != null,
|
|
5139
|
+
)
|
|
5140
|
+
? []
|
|
5141
|
+
: Array.from(this.collection?.base ?? [])
|
|
5142
|
+
.filter(
|
|
5143
|
+
([key, row]) =>
|
|
5144
|
+
!this.collection?._hasHydratedKey(key) &&
|
|
5145
|
+
!matchesAnySubset(row, evaluators),
|
|
5146
|
+
)
|
|
5147
|
+
.map(([key]) => key)
|
|
3923
5148
|
|
|
3924
5149
|
this.hydratedDemands.clear()
|
|
3925
5150
|
this.hydrationSequence++
|
|
@@ -3928,25 +5153,95 @@ class PersistedCollectionRuntime<
|
|
|
3928
5153
|
this.hydratingGeneration = lifecycleGeneration
|
|
3929
5154
|
try {
|
|
3930
5155
|
const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
5156
|
+
let validationSnapshot:
|
|
5157
|
+
Awaited<ReturnType<typeof adapter.loadResumeSnapshot>> | undefined
|
|
5158
|
+
let collectionMetadata: Array<{ key: string; value: unknown }> = []
|
|
5159
|
+
try {
|
|
5160
|
+
validationSnapshot =
|
|
5161
|
+
(activeSubsetOptions.length === 0 || outsideDemandKeys.length > 0) &&
|
|
5162
|
+
this.collection?.base.size
|
|
5163
|
+
? await adapter.loadResumeSnapshot(this.storageTargetFor(), {
|
|
5164
|
+
requiredIndexSignatures: this.getRequiredIndexSignatures(),
|
|
5165
|
+
includeRows: true,
|
|
5166
|
+
cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
|
|
5167
|
+
})
|
|
5168
|
+
: undefined
|
|
5169
|
+
collectionMetadata = validationSnapshot
|
|
5170
|
+
? validationSnapshot.collectionMetadata
|
|
5171
|
+
: await this.loadCollectionMetadataSnapshot(adapter)
|
|
3936
5172
|
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
3937
|
-
for (const
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
5173
|
+
for (const options of activeSubsetOptions) {
|
|
5174
|
+
const subsetRows = await this.loadSubsetRowsUnsafe(options, adapter)
|
|
5175
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
5176
|
+
for (const row of subsetRows) {
|
|
5177
|
+
mergedRows.set(row.key, {
|
|
5178
|
+
value: row.value,
|
|
5179
|
+
metadata: row.metadata,
|
|
5180
|
+
})
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
5183
|
+
if (outsideDemandKeys.length > 0 && validationSnapshot) {
|
|
5184
|
+
const durableRows = new Map(
|
|
5185
|
+
validationSnapshot.rows.map((row) => [row.key, row]),
|
|
5186
|
+
)
|
|
5187
|
+
for (const key of outsideDemandKeys) {
|
|
5188
|
+
if (mergedRows.has(key)) continue
|
|
5189
|
+
const row = durableRows.get(key)
|
|
5190
|
+
if (row) {
|
|
5191
|
+
mergedRows.set(key, {
|
|
5192
|
+
value: row.value as T,
|
|
5193
|
+
metadata: row.metadata,
|
|
5194
|
+
})
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
}
|
|
5198
|
+
} catch (error) {
|
|
5199
|
+
// Recovery may have started while the adapter read was awaited.
|
|
5200
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
5201
|
+
if (this.scopedRecovery) return
|
|
5202
|
+
// SQLite may reject its second read-admission check after a claim
|
|
5203
|
+
// expires. A read from that cache has no authority to fail the
|
|
5204
|
+
// Collection; recovery will reload its active demands.
|
|
5205
|
+
if (!this.cacheGenerationClaim || !this.hasExpiredCacheClaim()) {
|
|
5206
|
+
throw error
|
|
3942
5207
|
}
|
|
5208
|
+
this.scheduleCacheClaimRecovery(lifecycleGeneration)
|
|
5209
|
+
return
|
|
5210
|
+
}
|
|
5211
|
+
|
|
5212
|
+
// A peer reload can hold its persisted read past claim expiry. Its
|
|
5213
|
+
// rows and metadata cannot publish under the old generation. Recovery
|
|
5214
|
+
// needs this apply mutex, so start it only after this callback returns.
|
|
5215
|
+
if (this.isScopedRecoveryPending()) return
|
|
5216
|
+
if (this.cacheGenerationClaim && !(await this.renewCacheClaim(adapter))) {
|
|
5217
|
+
this.scheduleCacheClaimRecovery(lifecycleGeneration)
|
|
5218
|
+
return
|
|
3943
5219
|
}
|
|
5220
|
+
if (
|
|
5221
|
+
this.isScopedRecoveryPending() ||
|
|
5222
|
+
lifecycleGeneration !== this.lifecycleGeneration
|
|
5223
|
+
)
|
|
5224
|
+
return
|
|
3944
5225
|
|
|
3945
5226
|
for (const key of mergedRows.keys()) {
|
|
3946
5227
|
hydrationContext.suppliedRowKeys.add(key)
|
|
3947
5228
|
}
|
|
3948
5229
|
|
|
3949
|
-
if (
|
|
5230
|
+
if (activeSubsetOptions.length === 0) {
|
|
5231
|
+
// Retired demand does not hide already public rows. Validate only
|
|
5232
|
+
// those keys; do not publish unrelated durable rows without demand.
|
|
5233
|
+
await whenSyncAccepted(
|
|
5234
|
+
this.applyRowsToCollection(
|
|
5235
|
+
[],
|
|
5236
|
+
(validationSnapshot?.rows ?? []) as Array<{
|
|
5237
|
+
key: TKey
|
|
5238
|
+
value: T
|
|
5239
|
+
metadata?: unknown
|
|
5240
|
+
}>,
|
|
5241
|
+
collectionMetadata,
|
|
5242
|
+
),
|
|
5243
|
+
)
|
|
5244
|
+
} else if (
|
|
3950
5245
|
skipUnchangedReplace &&
|
|
3951
5246
|
this.matchesCollectionSnapshot(mergedRows, collectionMetadata)
|
|
3952
5247
|
) {
|
|
@@ -3972,6 +5267,7 @@ class PersistedCollectionRuntime<
|
|
|
3972
5267
|
metadata: row.metadata,
|
|
3973
5268
|
})),
|
|
3974
5269
|
collectionMetadata,
|
|
5270
|
+
replayDemand,
|
|
3975
5271
|
)
|
|
3976
5272
|
await whenSyncAccepted(applied)
|
|
3977
5273
|
}
|
|
@@ -4014,7 +5310,12 @@ class PersistedCollectionRuntime<
|
|
|
4014
5310
|
}
|
|
4015
5311
|
|
|
4016
5312
|
this.indexAddedUnsubscribe = this.collection.on(`index:added`, (event) => {
|
|
4017
|
-
void this.ensurePersistedIndex(event.index)
|
|
5313
|
+
void this.ensurePersistedIndex(event.index).catch((error) => {
|
|
5314
|
+
console.warn(
|
|
5315
|
+
`Failed to ensure persisted index after index:added:`,
|
|
5316
|
+
error,
|
|
5317
|
+
)
|
|
5318
|
+
})
|
|
4018
5319
|
})
|
|
4019
5320
|
this.indexRemovedUnsubscribe = this.collection.on(
|
|
4020
5321
|
`index:removed`,
|
|
@@ -4036,8 +5337,22 @@ class PersistedCollectionRuntime<
|
|
|
4036
5337
|
const indexMetadata =
|
|
4037
5338
|
indexMetadataSnapshot ?? collection?.getIndexMetadata() ?? []
|
|
4038
5339
|
const completedLocalIndexSignatures = new Set<string>()
|
|
5340
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
4039
5341
|
for (const metadata of indexMetadata) {
|
|
4040
|
-
|
|
5342
|
+
const storageCollectionId = this.storageCollectionId
|
|
5343
|
+
const claimId = this.cacheGenerationClaim?.claimId
|
|
5344
|
+
const ensured = await this.ensureLocalPersistedIndex(metadata, adapter)
|
|
5345
|
+
if (
|
|
5346
|
+
ensured &&
|
|
5347
|
+
(await this.reconcilePersistedIndex(
|
|
5348
|
+
metadata,
|
|
5349
|
+
adapter,
|
|
5350
|
+
storageCollectionId,
|
|
5351
|
+
claimId,
|
|
5352
|
+
lifecycleGeneration,
|
|
5353
|
+
true,
|
|
5354
|
+
))
|
|
5355
|
+
) {
|
|
4041
5356
|
completedLocalIndexSignatures.add(metadata.signature)
|
|
4042
5357
|
}
|
|
4043
5358
|
}
|
|
@@ -4048,12 +5363,61 @@ class PersistedCollectionRuntime<
|
|
|
4048
5363
|
indexMetadata: Array<CollectionIndexMetadata>,
|
|
4049
5364
|
completedLocalIndexSignatures: ReadonlySet<string>,
|
|
4050
5365
|
): Promise<void> {
|
|
5366
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
4051
5367
|
for (const metadata of indexMetadata) {
|
|
5368
|
+
if (!this.hasCurrentIndex(metadata)) continue
|
|
5369
|
+
const storageCollectionId = this.storageCollectionId
|
|
5370
|
+
const claimId = this.cacheGenerationClaim?.claimId
|
|
4052
5371
|
await this.requestCoordinatorPersistedIndex(
|
|
4053
5372
|
metadata,
|
|
4054
5373
|
completedLocalIndexSignatures.has(metadata.signature),
|
|
4055
5374
|
)
|
|
5375
|
+
await this.reconcilePersistedIndex(
|
|
5376
|
+
metadata,
|
|
5377
|
+
this.persistence.adapter,
|
|
5378
|
+
storageCollectionId,
|
|
5379
|
+
claimId,
|
|
5380
|
+
lifecycleGeneration,
|
|
5381
|
+
true,
|
|
5382
|
+
)
|
|
5383
|
+
}
|
|
5384
|
+
}
|
|
5385
|
+
|
|
5386
|
+
/** Settle a late index operation against the Collection's current declaration. */
|
|
5387
|
+
private async reconcilePersistedIndex(
|
|
5388
|
+
indexMetadata: CollectionIndexMetadata,
|
|
5389
|
+
adapter: HydrationPersistenceAdapter,
|
|
5390
|
+
storageCollectionId: string,
|
|
5391
|
+
claimId: string | undefined,
|
|
5392
|
+
lifecycleGeneration: number,
|
|
5393
|
+
indexPresent: boolean,
|
|
5394
|
+
): Promise<boolean> {
|
|
5395
|
+
while (
|
|
5396
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
5397
|
+
storageCollectionId === this.storageCollectionId
|
|
5398
|
+
) {
|
|
5399
|
+
const declared = this.hasCurrentIndex(indexMetadata)
|
|
5400
|
+
if (declared === indexPresent) return declared
|
|
5401
|
+
if (declared) {
|
|
5402
|
+
if (!(await this.ensureLocalPersistedIndex(indexMetadata, adapter))) {
|
|
5403
|
+
return false
|
|
5404
|
+
}
|
|
5405
|
+
} else {
|
|
5406
|
+
if (!adapter.markIndexRemoved) return false
|
|
5407
|
+
try {
|
|
5408
|
+
await adapter.markIndexRemoved(
|
|
5409
|
+
this.storageTargetFor(storageCollectionId, claimId),
|
|
5410
|
+
indexMetadata.signature,
|
|
5411
|
+
{ cacheGenerationClaimId: claimId },
|
|
5412
|
+
)
|
|
5413
|
+
} catch (error) {
|
|
5414
|
+
console.warn(`Failed to mark persisted index removed:`, error)
|
|
5415
|
+
return false
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
indexPresent = declared
|
|
4056
5419
|
}
|
|
5420
|
+
return false
|
|
4057
5421
|
}
|
|
4058
5422
|
|
|
4059
5423
|
private buildPersistedIndexSpec(
|
|
@@ -4073,11 +5437,39 @@ class PersistedCollectionRuntime<
|
|
|
4073
5437
|
indexMetadata: CollectionIndexMetadata,
|
|
4074
5438
|
adapter: HydrationPersistenceAdapter = this.persistence.adapter,
|
|
4075
5439
|
): Promise<void> {
|
|
5440
|
+
if (this.scopedRecoveryPending) return
|
|
5441
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
5442
|
+
await this.ensureCacheClaimActive()
|
|
5443
|
+
if (
|
|
5444
|
+
lifecycleGeneration !== this.lifecycleGeneration ||
|
|
5445
|
+
!this.hasCurrentIndex(indexMetadata)
|
|
5446
|
+
)
|
|
5447
|
+
return
|
|
4076
5448
|
const completedLocally = await this.ensureLocalPersistedIndex(
|
|
4077
5449
|
indexMetadata,
|
|
4078
5450
|
adapter,
|
|
4079
5451
|
)
|
|
5452
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) return
|
|
5453
|
+
if (!this.hasCurrentIndex(indexMetadata)) {
|
|
5454
|
+
await this.markIndexRemoved(indexMetadata)
|
|
5455
|
+
return
|
|
5456
|
+
}
|
|
4080
5457
|
await this.requestCoordinatorPersistedIndex(indexMetadata, completedLocally)
|
|
5458
|
+
if (
|
|
5459
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
5460
|
+
!this.hasCurrentIndex(indexMetadata)
|
|
5461
|
+
) {
|
|
5462
|
+
await this.markIndexRemoved(indexMetadata)
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
private hasCurrentIndex(indexMetadata: CollectionIndexMetadata): boolean {
|
|
5467
|
+
return (
|
|
5468
|
+
this.collection
|
|
5469
|
+
?.getIndexMetadata()
|
|
5470
|
+
.some((current) => current.signature === indexMetadata.signature) ??
|
|
5471
|
+
false
|
|
5472
|
+
)
|
|
4081
5473
|
}
|
|
4082
5474
|
|
|
4083
5475
|
private async ensureLocalPersistedIndex(
|
|
@@ -4087,9 +5479,10 @@ class PersistedCollectionRuntime<
|
|
|
4087
5479
|
try {
|
|
4088
5480
|
const spec = this.buildPersistedIndexSpec(indexMetadata)
|
|
4089
5481
|
await adapter.ensureIndex(
|
|
4090
|
-
this.
|
|
5482
|
+
this.storageTargetFor(),
|
|
4091
5483
|
indexMetadata.signature,
|
|
4092
5484
|
spec,
|
|
5485
|
+
{ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId },
|
|
4093
5486
|
)
|
|
4094
5487
|
return true
|
|
4095
5488
|
} catch (error) {
|
|
@@ -4105,11 +5498,12 @@ class PersistedCollectionRuntime<
|
|
|
4105
5498
|
try {
|
|
4106
5499
|
const spec = this.buildPersistedIndexSpec(indexMetadata)
|
|
4107
5500
|
await this.persistence.coordinator.requestEnsurePersistedIndex(
|
|
4108
|
-
this.
|
|
5501
|
+
this.storageCollectionId,
|
|
4109
5502
|
indexMetadata.signature,
|
|
4110
5503
|
spec,
|
|
4111
5504
|
completedLocally ? this.persistence.adapter : undefined,
|
|
4112
5505
|
completedLocally,
|
|
5506
|
+
this.cacheGenerationClaim?.claimId,
|
|
4113
5507
|
)
|
|
4114
5508
|
} catch (error) {
|
|
4115
5509
|
console.warn(
|
|
@@ -4127,10 +5521,25 @@ class PersistedCollectionRuntime<
|
|
|
4127
5521
|
}
|
|
4128
5522
|
|
|
4129
5523
|
try {
|
|
5524
|
+
if (this.scopedRecoveryPending) return
|
|
5525
|
+
const lifecycleGeneration = this.lifecycleGeneration
|
|
5526
|
+
await this.ensureCacheClaimActive()
|
|
5527
|
+
if (
|
|
5528
|
+
lifecycleGeneration !== this.lifecycleGeneration ||
|
|
5529
|
+
this.hasCurrentIndex(indexMetadata)
|
|
5530
|
+
)
|
|
5531
|
+
return
|
|
4130
5532
|
await this.persistence.adapter.markIndexRemoved(
|
|
4131
|
-
this.
|
|
5533
|
+
this.storageTargetFor(),
|
|
4132
5534
|
indexMetadata.signature,
|
|
5535
|
+
{ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId },
|
|
4133
5536
|
)
|
|
5537
|
+
if (
|
|
5538
|
+
lifecycleGeneration === this.lifecycleGeneration &&
|
|
5539
|
+
this.hasCurrentIndex(indexMetadata)
|
|
5540
|
+
) {
|
|
5541
|
+
await this.ensurePersistedIndex(indexMetadata)
|
|
5542
|
+
}
|
|
4134
5543
|
} catch (error) {
|
|
4135
5544
|
console.warn(`Failed to mark persisted index removed:`, error)
|
|
4136
5545
|
}
|
|
@@ -4285,6 +5694,7 @@ function createWrappedSyncConfig<
|
|
|
4285
5694
|
const isCleanedUp = () => startupState.cleanedUp
|
|
4286
5695
|
type SubsetAcquisition = {
|
|
4287
5696
|
forwarded: boolean
|
|
5697
|
+
source?: SyncConfigRes
|
|
4288
5698
|
cancelAdmissionWait?: () => void
|
|
4289
5699
|
}
|
|
4290
5700
|
const acquisitions = new Map<LoadSubsetOptions, SubsetAcquisition>()
|
|
@@ -4402,21 +5812,27 @@ function createWrappedSyncConfig<
|
|
|
4402
5812
|
throw error
|
|
4403
5813
|
}
|
|
4404
5814
|
}
|
|
4405
|
-
runtime.setSyncControls({
|
|
4406
|
-
begin: params.begin,
|
|
4407
|
-
write: params.write as SyncControlFns<T, TKey>[`write`],
|
|
4408
|
-
commit: params.commit,
|
|
4409
|
-
truncate: params.truncate,
|
|
4410
|
-
metadata: params.metadata ?? null,
|
|
4411
|
-
markError: params.markError,
|
|
4412
|
-
})
|
|
4413
5815
|
runtime.setCollection(
|
|
4414
5816
|
params.collection as Collection<T, TKey, PersistedCollectionUtils>,
|
|
5817
|
+
{
|
|
5818
|
+
begin: params.begin,
|
|
5819
|
+
write: params.write as SyncControlFns<T, TKey>[`write`],
|
|
5820
|
+
commit: params.commit,
|
|
5821
|
+
truncate: params.truncate,
|
|
5822
|
+
metadata: params.metadata ?? null,
|
|
5823
|
+
markError: params.markError,
|
|
5824
|
+
},
|
|
4415
5825
|
)
|
|
4416
5826
|
|
|
4417
5827
|
const persistenceCapability: SyncPersistenceCapabilityV1<TKey> = {
|
|
4418
5828
|
protocol: SYNC_PERSISTENCE_PROTOCOL,
|
|
4419
5829
|
version: SYNC_PERSISTENCE_VERSION,
|
|
5830
|
+
get managedCacheGeneration() {
|
|
5831
|
+
return runtime.hasManagedCacheGeneration()
|
|
5832
|
+
},
|
|
5833
|
+
get startupCacheGenerationRotated() {
|
|
5834
|
+
return runtime.hasRotatedStartupCacheGeneration()
|
|
5835
|
+
},
|
|
4420
5836
|
reserveCommitTurn: () => {
|
|
4421
5837
|
const openTransaction = getOpenTransaction()
|
|
4422
5838
|
if (openTransaction && !openTransaction.internal)
|
|
@@ -4430,6 +5846,10 @@ function createWrappedSyncConfig<
|
|
|
4430
5846
|
throw runtime.reportSyncError(error)
|
|
4431
5847
|
}
|
|
4432
5848
|
},
|
|
5849
|
+
startScopedRecovery: (resetMetadata) =>
|
|
5850
|
+
startupState.cleanedUp
|
|
5851
|
+
? Promise.resolve()
|
|
5852
|
+
: runtime.startScopedRecovery(resetMetadata),
|
|
4433
5853
|
scanPersistedRows: (options) =>
|
|
4434
5854
|
startupState.cleanedUp
|
|
4435
5855
|
? Promise.resolve([])
|
|
@@ -4459,6 +5879,7 @@ function createWrappedSyncConfig<
|
|
|
4459
5879
|
},
|
|
4460
5880
|
}
|
|
4461
5881
|
|
|
5882
|
+
let sourceSession = 0
|
|
4462
5883
|
const wrappedParams = {
|
|
4463
5884
|
...params,
|
|
4464
5885
|
markError: reportUpstreamError,
|
|
@@ -4788,6 +6209,12 @@ function createWrappedSyncConfig<
|
|
|
4788
6209
|
settlePublicationAdmissionWaiters(openTransaction, error)
|
|
4789
6210
|
return createHandledRejection(error)
|
|
4790
6211
|
}
|
|
6212
|
+
if (runtime.isScopedRecoveryPending()) {
|
|
6213
|
+
settlePendingTransaction(openTransaction)
|
|
6214
|
+
const error = new SyncTransactionAbortedError()
|
|
6215
|
+
settlePublicationAdmissionWaiters(openTransaction, error)
|
|
6216
|
+
return createHandledRejection(error)
|
|
6217
|
+
}
|
|
4791
6218
|
try {
|
|
4792
6219
|
bindToCurrentHydration(openTransaction)
|
|
4793
6220
|
assertHydrationSequenceCurrent(openTransaction)
|
|
@@ -4880,9 +6307,183 @@ function createWrappedSyncConfig<
|
|
|
4880
6307
|
},
|
|
4881
6308
|
}
|
|
4882
6309
|
|
|
6310
|
+
// Each invocation of a custom source owns its callbacks. Retiring that
|
|
6311
|
+
// invocation before rotation makes late work harmless even when the
|
|
6312
|
+
// source does not provide its own restart hook.
|
|
6313
|
+
const sourceParamsFor = (session: number) => {
|
|
6314
|
+
const active = () =>
|
|
6315
|
+
!startupState.cleanedUp && session === sourceSession
|
|
6316
|
+
const metadata = wrappedParams.metadata
|
|
6317
|
+
return {
|
|
6318
|
+
...wrappedParams,
|
|
6319
|
+
markReady: () => {
|
|
6320
|
+
if (active()) wrappedParams.markReady()
|
|
6321
|
+
},
|
|
6322
|
+
markError: (error?: unknown) => {
|
|
6323
|
+
if (active()) wrappedParams.markError(error)
|
|
6324
|
+
},
|
|
6325
|
+
begin: () => {
|
|
6326
|
+
if (active()) wrappedParams.begin()
|
|
6327
|
+
},
|
|
6328
|
+
write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
|
|
6329
|
+
if (active()) wrappedParams.write(message)
|
|
6330
|
+
},
|
|
6331
|
+
truncate: () => {
|
|
6332
|
+
if (active()) wrappedParams.truncate()
|
|
6333
|
+
},
|
|
6334
|
+
commit: (signal?: AbortSignal) =>
|
|
6335
|
+
active() ? wrappedParams.commit(signal) : true,
|
|
6336
|
+
metadata: metadata && {
|
|
6337
|
+
...metadata,
|
|
6338
|
+
row: {
|
|
6339
|
+
get: (key: TKey) =>
|
|
6340
|
+
active() ? metadata.row.get(key) : undefined,
|
|
6341
|
+
set: (key: TKey, value: unknown) => {
|
|
6342
|
+
if (active()) metadata.row.set(key, value)
|
|
6343
|
+
},
|
|
6344
|
+
delete: (key: TKey) => {
|
|
6345
|
+
if (active()) metadata.row.delete(key)
|
|
6346
|
+
},
|
|
6347
|
+
},
|
|
6348
|
+
collection: {
|
|
6349
|
+
get: (key: string) =>
|
|
6350
|
+
active() ? metadata.collection.get(key) : undefined,
|
|
6351
|
+
set: (key: string, value: unknown) => {
|
|
6352
|
+
if (active()) metadata.collection.set(key, value)
|
|
6353
|
+
},
|
|
6354
|
+
delete: (key: string) => {
|
|
6355
|
+
if (active()) metadata.collection.delete(key)
|
|
6356
|
+
},
|
|
6357
|
+
list: (prefix?: string) =>
|
|
6358
|
+
active() ? metadata.collection.list(prefix) : [],
|
|
6359
|
+
},
|
|
6360
|
+
persistence: {
|
|
6361
|
+
...metadata.persistence,
|
|
6362
|
+
reserveCommitTurn: () => {
|
|
6363
|
+
if (active()) metadata.persistence.reserveCommitTurn()
|
|
6364
|
+
},
|
|
6365
|
+
hydrateBaseline: () =>
|
|
6366
|
+
active()
|
|
6367
|
+
? metadata.persistence.hydrateBaseline()
|
|
6368
|
+
: Promise.resolve(),
|
|
6369
|
+
startScopedRecovery: (resetMetadata?: {
|
|
6370
|
+
key: string
|
|
6371
|
+
value: unknown
|
|
6372
|
+
}) =>
|
|
6373
|
+
active()
|
|
6374
|
+
? (metadata.persistence.startScopedRecovery?.(
|
|
6375
|
+
resetMetadata,
|
|
6376
|
+
) ?? Promise.resolve())
|
|
6377
|
+
: Promise.resolve(),
|
|
6378
|
+
scanPersistedRows: (
|
|
6379
|
+
options?: Parameters<
|
|
6380
|
+
typeof metadata.persistence.scanPersistedRows
|
|
6381
|
+
>[0],
|
|
6382
|
+
) =>
|
|
6383
|
+
active()
|
|
6384
|
+
? metadata.persistence.scanPersistedRows(options)
|
|
6385
|
+
: Promise.resolve([]),
|
|
6386
|
+
resumeSnapshot: {
|
|
6387
|
+
certify: () =>
|
|
6388
|
+
active()
|
|
6389
|
+
? metadata.persistence.resumeSnapshot.certify()
|
|
6390
|
+
: Promise.resolve(),
|
|
6391
|
+
getKeySetEvidence: () =>
|
|
6392
|
+
active()
|
|
6393
|
+
? metadata.persistence.resumeSnapshot.getKeySetEvidence()
|
|
6394
|
+
: undefined,
|
|
6395
|
+
expectCurrentCommit: () => {
|
|
6396
|
+
if (active())
|
|
6397
|
+
metadata.persistence.resumeSnapshot.expectCurrentCommit()
|
|
6398
|
+
},
|
|
6399
|
+
},
|
|
6400
|
+
},
|
|
6401
|
+
},
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6404
|
+
|
|
4883
6405
|
let sourceResult: SyncConfigRes = {}
|
|
4884
6406
|
let sourceResultSettled = false
|
|
4885
6407
|
let activeSourceSyncEntry: Promise<void> | undefined
|
|
6408
|
+
let sourceCleanupPromise: Promise<void> | undefined
|
|
6409
|
+
let customRestartSequence = 0
|
|
6410
|
+
const enterSourceSession = (): void => {
|
|
6411
|
+
const session = ++sourceSession
|
|
6412
|
+
let settleSourceSyncEntry!: () => void
|
|
6413
|
+
activeSourceSyncEntry = new Promise<void>((resolve) => {
|
|
6414
|
+
settleSourceSyncEntry = resolve
|
|
6415
|
+
})
|
|
6416
|
+
try {
|
|
6417
|
+
sourceResult = normalizeSyncFnResult(
|
|
6418
|
+
sourceSyncConfig.sync(
|
|
6419
|
+
runtime.hasManagedCacheGeneration()
|
|
6420
|
+
? sourceParamsFor(session)
|
|
6421
|
+
: wrappedParams,
|
|
6422
|
+
),
|
|
6423
|
+
)
|
|
6424
|
+
// A coordinator acquisition owns the source session that accepted
|
|
6425
|
+
// it. A late release must not unload the replacement session.
|
|
6426
|
+
if (!startupState.cleanedUp && sourceResult.loadSubset) {
|
|
6427
|
+
const ownerSource = sourceResult
|
|
6428
|
+
const loadSubset = async (
|
|
6429
|
+
options: TransportedLoadSubsetOptions,
|
|
6430
|
+
) => {
|
|
6431
|
+
if (startupState.cleanedUp || session !== sourceSession) {
|
|
6432
|
+
throw new Error(`persisted sync source is no longer active`)
|
|
6433
|
+
}
|
|
6434
|
+
await ownerSource.loadSubset?.(
|
|
6435
|
+
options as unknown as LoadSubsetOptions,
|
|
6436
|
+
)
|
|
6437
|
+
}
|
|
6438
|
+
runtime.registerRemoteSubsetOwner(
|
|
6439
|
+
Object.assign(loadSubset, {
|
|
6440
|
+
unloadSubset: (options: TransportedLoadSubsetOptions) =>
|
|
6441
|
+
ownerSource.unloadSubset?.(
|
|
6442
|
+
options as unknown as LoadSubsetOptions,
|
|
6443
|
+
),
|
|
6444
|
+
onError: (error: unknown) => {
|
|
6445
|
+
if (session === sourceSession) reportUpstreamError(error)
|
|
6446
|
+
},
|
|
6447
|
+
}),
|
|
6448
|
+
)
|
|
6449
|
+
}
|
|
6450
|
+
} finally {
|
|
6451
|
+
activeSourceSyncEntry = undefined
|
|
6452
|
+
settleSourceSyncEntry()
|
|
6453
|
+
}
|
|
6454
|
+
}
|
|
6455
|
+
const restartCustomSource = (cacheRotated: Promise<void>) => {
|
|
6456
|
+
const restartSequence = ++customRestartSequence
|
|
6457
|
+
// Synchronous retirement is required by the recovery boundary. Any
|
|
6458
|
+
// open old-source transaction is abandoned before the new cache binds.
|
|
6459
|
+
if (!sourceCleanupPromise) {
|
|
6460
|
+
sourceSession++
|
|
6461
|
+
const abortError = new SyncTransactionAbortedError()
|
|
6462
|
+
for (const transaction of transactionStack) {
|
|
6463
|
+
settlePublicationAdmissionWaiters(transaction, abortError)
|
|
6464
|
+
}
|
|
6465
|
+
transactionStack.length = 0
|
|
6466
|
+
const retiredResult = sourceResult
|
|
6467
|
+
sourceCleanupPromise = Promise.resolve().then(() =>
|
|
6468
|
+
retiredResult.cleanup?.(),
|
|
6469
|
+
)
|
|
6470
|
+
}
|
|
6471
|
+
return Promise.all([sourceCleanupPromise, cacheRotated]).then(() => {
|
|
6472
|
+
if (
|
|
6473
|
+
startupState.cleanedUp ||
|
|
6474
|
+
restartSequence !== customRestartSequence
|
|
6475
|
+
)
|
|
6476
|
+
return
|
|
6477
|
+
sourceCleanupPromise = undefined
|
|
6478
|
+
enterSourceSession()
|
|
6479
|
+
runtime.setSourceRestartAfterScopedRecovery(
|
|
6480
|
+
sourceResult.restartAfterScopedRecovery ??
|
|
6481
|
+
(runtime.hasManagedCacheGeneration()
|
|
6482
|
+
? restartCustomSource
|
|
6483
|
+
: undefined),
|
|
6484
|
+
)
|
|
6485
|
+
})
|
|
6486
|
+
}
|
|
4886
6487
|
fullStartPromise = runtime.ensureStarted()
|
|
4887
6488
|
// Startup can fail before the source reaches markReady or loadSubset,
|
|
4888
6489
|
// which are the two eventual consumers of this outer adopting promise.
|
|
@@ -4898,40 +6499,14 @@ function createWrappedSyncConfig<
|
|
|
4898
6499
|
return sourceResult
|
|
4899
6500
|
}
|
|
4900
6501
|
|
|
4901
|
-
|
|
4902
|
-
activeSourceSyncEntry = new Promise<void>((resolve) => {
|
|
4903
|
-
settleSourceSyncEntry = resolve
|
|
4904
|
-
})
|
|
4905
|
-
try {
|
|
4906
|
-
sourceResult = normalizeSyncFnResult(
|
|
4907
|
-
sourceSyncConfig.sync(wrappedParams),
|
|
4908
|
-
)
|
|
4909
|
-
} finally {
|
|
4910
|
-
activeSourceSyncEntry = undefined
|
|
4911
|
-
settleSourceSyncEntry()
|
|
4912
|
-
}
|
|
4913
|
-
// Cleanup can reenter while the source sync function is still active.
|
|
4914
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
4915
|
-
if (!startupState.cleanedUp && sourceResult.loadSubset) {
|
|
4916
|
-
const loadSubset = async (options: TransportedLoadSubsetOptions) => {
|
|
4917
|
-
if (startupState.cleanedUp) {
|
|
4918
|
-
throw new Error(`persisted sync source is no longer active`)
|
|
4919
|
-
}
|
|
4920
|
-
await sourceResult.loadSubset?.(
|
|
4921
|
-
options as unknown as LoadSubsetOptions,
|
|
4922
|
-
)
|
|
4923
|
-
}
|
|
4924
|
-
runtime.registerRemoteSubsetOwner(
|
|
4925
|
-
Object.assign(loadSubset, {
|
|
4926
|
-
unloadSubset: (options: TransportedLoadSubsetOptions) =>
|
|
4927
|
-
sourceResult.unloadSubset?.(
|
|
4928
|
-
options as unknown as LoadSubsetOptions,
|
|
4929
|
-
),
|
|
4930
|
-
onError: reportUpstreamError,
|
|
4931
|
-
}),
|
|
4932
|
-
)
|
|
4933
|
-
}
|
|
6502
|
+
enterSourceSession()
|
|
4934
6503
|
sourceResultSettled = true
|
|
6504
|
+
runtime.setSourceRestartAfterScopedRecovery(
|
|
6505
|
+
sourceResult.restartAfterScopedRecovery ??
|
|
6506
|
+
(runtime.hasManagedCacheGeneration()
|
|
6507
|
+
? restartCustomSource
|
|
6508
|
+
: undefined),
|
|
6509
|
+
)
|
|
4935
6510
|
return sourceResult
|
|
4936
6511
|
})()
|
|
4937
6512
|
resolveSourceResultAssigned()
|
|
@@ -4942,6 +6517,7 @@ function createWrappedSyncConfig<
|
|
|
4942
6517
|
return {
|
|
4943
6518
|
cleanup: async () => {
|
|
4944
6519
|
startupState.cleanedUp = true
|
|
6520
|
+
runtime.clearSourceRestartAfterScopedRecovery()
|
|
4945
6521
|
// The optional source starts behind persistence metadata. If it
|
|
4946
6522
|
// reenters cleanup before returning, wait for that exact synchronous
|
|
4947
6523
|
// entry to publish its cleanup callback before sampling the result.
|
|
@@ -4958,9 +6534,11 @@ function createWrappedSyncConfig<
|
|
|
4958
6534
|
transactionStack.length = 0
|
|
4959
6535
|
let sourceCleanup: Promise<void>
|
|
4960
6536
|
try {
|
|
4961
|
-
sourceCleanup =
|
|
4962
|
-
|
|
4963
|
-
|
|
6537
|
+
sourceCleanup =
|
|
6538
|
+
sourceCleanupPromise ??
|
|
6539
|
+
(sourceSyncEntry
|
|
6540
|
+
? sourceSyncEntry.then(() => sourceResult.cleanup?.())
|
|
6541
|
+
: Promise.resolve(sourceResult.cleanup?.()))
|
|
4964
6542
|
} catch (error) {
|
|
4965
6543
|
sourceCleanup = Promise.reject(error)
|
|
4966
6544
|
}
|
|
@@ -4988,10 +6566,9 @@ function createWrappedSyncConfig<
|
|
|
4988
6566
|
if (runtimeOutcome.status === `rejected`) throw runtimeOutcome.reason
|
|
4989
6567
|
},
|
|
4990
6568
|
loadSubset: (options: LoadSubsetOptions): true | Promise<void> => {
|
|
4991
|
-
const acquisition = { forwarded: false }
|
|
6569
|
+
const acquisition: SubsetAcquisition = { forwarded: false }
|
|
4992
6570
|
acquisitions.set(options, acquisition)
|
|
4993
6571
|
const forwardUpstream = (
|
|
4994
|
-
resolvedSourceResult: SyncConfigRes,
|
|
4995
6572
|
loadOptions: LoadSubsetOptions,
|
|
4996
6573
|
): true | Promise<void> => {
|
|
4997
6574
|
// Hydration is another async boundary. A release before this
|
|
@@ -5002,14 +6579,17 @@ function createWrappedSyncConfig<
|
|
|
5002
6579
|
) {
|
|
5003
6580
|
return true
|
|
5004
6581
|
}
|
|
5005
|
-
|
|
6582
|
+
const currentSource = sourceResult
|
|
6583
|
+
if (!currentSource.loadSubset) return true
|
|
5006
6584
|
acquisition.forwarded = true
|
|
6585
|
+
acquisition.source = currentSource
|
|
5007
6586
|
try {
|
|
5008
6587
|
// Returning a promise transfers its lease even if it rejects.
|
|
5009
6588
|
// Only a synchronous throw leaves no upstream lease to release.
|
|
5010
|
-
return
|
|
6589
|
+
return currentSource.loadSubset(loadOptions)
|
|
5011
6590
|
} catch (error) {
|
|
5012
6591
|
acquisition.forwarded = false
|
|
6592
|
+
acquisition.source = undefined
|
|
5013
6593
|
throw error
|
|
5014
6594
|
}
|
|
5015
6595
|
}
|
|
@@ -5017,19 +6597,60 @@ function createWrappedSyncConfig<
|
|
|
5017
6597
|
const openTransaction = getOpenTransaction()
|
|
5018
6598
|
const needsPublicationAdmission =
|
|
5019
6599
|
openTransaction?.reservesCommitTurn === true
|
|
6600
|
+
let hydrationStarted = false
|
|
6601
|
+
const raceRetainedLoad = (
|
|
6602
|
+
work: Promise<void>,
|
|
6603
|
+
canAbortPromptly = () => true,
|
|
6604
|
+
): Promise<void> => {
|
|
6605
|
+
const signal = options.signal
|
|
6606
|
+
const abortable = !signal
|
|
6607
|
+
? work
|
|
6608
|
+
: new Promise<void>((resolve, reject) => {
|
|
6609
|
+
const abortError = () =>
|
|
6610
|
+
signal.reason ?? new SyncTransactionAbortedError()
|
|
6611
|
+
const onAbort = () => {
|
|
6612
|
+
// Once an uncancelable baseline read begins, its returned
|
|
6613
|
+
// load keeps core's overlapping replay private until the
|
|
6614
|
+
// resulting rows and receipts become visible.
|
|
6615
|
+
if (canAbortPromptly()) reject(abortError())
|
|
6616
|
+
}
|
|
6617
|
+
signal.addEventListener(`abort`, onAbort, { once: true })
|
|
6618
|
+
if (signal.aborted) onAbort()
|
|
6619
|
+
void work.then(
|
|
6620
|
+
() => {
|
|
6621
|
+
signal.removeEventListener(`abort`, onAbort)
|
|
6622
|
+
if (signal.aborted) reject(abortError())
|
|
6623
|
+
else resolve()
|
|
6624
|
+
},
|
|
6625
|
+
(error: unknown) => {
|
|
6626
|
+
signal.removeEventListener(`abort`, onAbort)
|
|
6627
|
+
reject(signal.aborted ? abortError() : error)
|
|
6628
|
+
},
|
|
6629
|
+
)
|
|
6630
|
+
})
|
|
6631
|
+
return abortable.catch((error: unknown) => {
|
|
6632
|
+
if (
|
|
6633
|
+
startupState.cleanedUp ||
|
|
6634
|
+
acquisitions.get(options) !== acquisition
|
|
6635
|
+
) {
|
|
6636
|
+
return
|
|
6637
|
+
}
|
|
6638
|
+
throw error
|
|
6639
|
+
})
|
|
6640
|
+
}
|
|
5020
6641
|
const hydrated =
|
|
5021
6642
|
sourceResultSettled && !needsPublicationAdmission
|
|
5022
6643
|
? runtime.loadHydratedSubset(options, (loadOptions) =>
|
|
5023
|
-
forwardUpstream(
|
|
6644
|
+
forwardUpstream(loadOptions),
|
|
5024
6645
|
)
|
|
5025
6646
|
: undefined
|
|
5026
6647
|
if (hydrated !== undefined) {
|
|
5027
|
-
return hydrated
|
|
6648
|
+
return hydrated === true ? true : raceRetainedLoad(hydrated)
|
|
5028
6649
|
}
|
|
5029
6650
|
|
|
5030
|
-
|
|
6651
|
+
const work = (async () => {
|
|
5031
6652
|
await fullStartPromise
|
|
5032
|
-
|
|
6653
|
+
await sourceResultPromise
|
|
5033
6654
|
if (
|
|
5034
6655
|
startupState.cleanedUp ||
|
|
5035
6656
|
acquisitions.get(options) !== acquisition
|
|
@@ -5052,10 +6673,18 @@ function createWrappedSyncConfig<
|
|
|
5052
6673
|
) {
|
|
5053
6674
|
return
|
|
5054
6675
|
}
|
|
5055
|
-
return runtime.loadSubset(
|
|
5056
|
-
|
|
6676
|
+
return runtime.loadSubset(
|
|
6677
|
+
options,
|
|
6678
|
+
(loadOptions) => forwardUpstream(loadOptions),
|
|
6679
|
+
() => {
|
|
6680
|
+
hydrationStarted = true
|
|
6681
|
+
},
|
|
6682
|
+
() =>
|
|
6683
|
+
!startupState.cleanedUp &&
|
|
6684
|
+
acquisitions.get(options) === acquisition,
|
|
5057
6685
|
)
|
|
5058
6686
|
})()
|
|
6687
|
+
return raceRetainedLoad(work, () => !hydrationStarted)
|
|
5059
6688
|
},
|
|
5060
6689
|
unloadSubset: (options: LoadSubsetOptions) => {
|
|
5061
6690
|
const acquisition = acquisitions.get(options)
|
|
@@ -5063,7 +6692,9 @@ function createWrappedSyncConfig<
|
|
|
5063
6692
|
acquisitions.delete(options)
|
|
5064
6693
|
runtime.unloadSubset(
|
|
5065
6694
|
options,
|
|
5066
|
-
acquisition?.forwarded
|
|
6695
|
+
acquisition?.forwarded
|
|
6696
|
+
? acquisition.source?.unloadSubset
|
|
6697
|
+
: undefined,
|
|
5067
6698
|
)
|
|
5068
6699
|
},
|
|
5069
6700
|
}
|
|
@@ -5077,16 +6708,16 @@ function createLoopbackSyncConfig<
|
|
|
5077
6708
|
>(runtime: PersistedCollectionRuntime<T, TKey>): SyncConfig<T, TKey> {
|
|
5078
6709
|
return {
|
|
5079
6710
|
sync: (params) => {
|
|
5080
|
-
runtime.setSyncControls({
|
|
5081
|
-
begin: params.begin,
|
|
5082
|
-
write: params.write as SyncControlFns<T, TKey>[`write`],
|
|
5083
|
-
commit: params.commit,
|
|
5084
|
-
truncate: params.truncate,
|
|
5085
|
-
metadata: params.metadata ?? null,
|
|
5086
|
-
markError: params.markError,
|
|
5087
|
-
})
|
|
5088
6711
|
runtime.setCollection(
|
|
5089
6712
|
params.collection as Collection<T, TKey, PersistedCollectionUtils>,
|
|
6713
|
+
{
|
|
6714
|
+
begin: params.begin,
|
|
6715
|
+
write: params.write as SyncControlFns<T, TKey>[`write`],
|
|
6716
|
+
commit: params.commit,
|
|
6717
|
+
truncate: params.truncate,
|
|
6718
|
+
metadata: params.metadata ?? null,
|
|
6719
|
+
markError: params.markError,
|
|
6720
|
+
},
|
|
5090
6721
|
)
|
|
5091
6722
|
|
|
5092
6723
|
void runtime
|
|
@@ -5206,6 +6837,20 @@ export function persistedCollectionOptions<
|
|
|
5206
6837
|
? new PersistedReadinessTracker(networkTimeoutMs)
|
|
5207
6838
|
: undefined
|
|
5208
6839
|
|
|
6840
|
+
let claimed = false
|
|
6841
|
+
let claimedRuntime: PersistedCollectionRuntime<T, TKey> | undefined
|
|
6842
|
+
const claimOptions = () => {
|
|
6843
|
+
if (claimed) {
|
|
6844
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
6845
|
+
`Persisted options can create only one Collection. Create fresh options for each Collection.`,
|
|
6846
|
+
)
|
|
6847
|
+
}
|
|
6848
|
+
claimed = true
|
|
6849
|
+
return () => {
|
|
6850
|
+
if (!claimedRuntime?.hasBoundCollection()) claimed = false
|
|
6851
|
+
}
|
|
6852
|
+
}
|
|
6853
|
+
|
|
5209
6854
|
if (hasOwnSyncKey(options)) {
|
|
5210
6855
|
if (!isValidSyncConfig(options.sync)) {
|
|
5211
6856
|
throw new InvalidSyncConfigError(
|
|
@@ -5236,7 +6881,9 @@ export function persistedCollectionOptions<
|
|
|
5236
6881
|
syncOptions.syncMode ?? `eager`,
|
|
5237
6882
|
collectionId,
|
|
5238
6883
|
persistedReadinessTracker,
|
|
6884
|
+
claimOptions,
|
|
5239
6885
|
)
|
|
6886
|
+
claimedRuntime = runtime
|
|
5240
6887
|
|
|
5241
6888
|
const result = {
|
|
5242
6889
|
...syncOptions,
|
|
@@ -5259,6 +6906,7 @@ export function persistedCollectionOptions<
|
|
|
5259
6906
|
...options,
|
|
5260
6907
|
id: collectionId,
|
|
5261
6908
|
} as never) as typeof result,
|
|
6909
|
+
claimOptions,
|
|
5262
6910
|
)
|
|
5263
6911
|
}
|
|
5264
6912
|
|
|
@@ -5284,7 +6932,9 @@ export function persistedCollectionOptions<
|
|
|
5284
6932
|
localOnlyOptions.syncMode ?? `eager`,
|
|
5285
6933
|
localOnlyOptions.id ?? DEFAULT_DB_NAME,
|
|
5286
6934
|
persistedReadinessTracker,
|
|
6935
|
+
claimOptions,
|
|
5287
6936
|
)
|
|
6937
|
+
claimedRuntime = runtime
|
|
5288
6938
|
|
|
5289
6939
|
const wrappedOnInsert = async (
|
|
5290
6940
|
params: InsertMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
|
|
@@ -5381,6 +7031,7 @@ export function persistedCollectionOptions<
|
|
|
5381
7031
|
...options,
|
|
5382
7032
|
id: collectionId,
|
|
5383
7033
|
} as never) as typeof result,
|
|
7034
|
+
claimOptions,
|
|
5384
7035
|
)
|
|
5385
7036
|
}
|
|
5386
7037
|
|