@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.
@@ -1,5 +1,5 @@
1
1
  import { LoadSubsetOptions } from '@tanstack/db';
2
- import { CommittedTxAnchor, HydrationPersistenceAdapter, PersistedIndexSpec, PersistedKeySetEvidence, PersistedRowScanOptions, PersistedScannedRow, PersistedTx, PersistenceAdapter, ReconciledCommittedTx, ReplayableTxDelta, SQLiteDriver } from './persisted.cjs';
2
+ import { CommittedTxAnchor, HydrationPersistenceAdapter, PersistedCacheGenerationClaim, PersistedIndexSpec, PersistedKeySetEvidence, PersistedRowScanOptions, PersistedScannedRow, PersistedStorageTarget, PersistedTx, PersistenceAdapter, ReconciledCommittedTx, ReplayableTxDelta, SQLiteDriver } from './persisted.cjs';
3
3
  type SQLiteCoreAdapterSchemaMismatchPolicy = `sync-present-reset` | `sync-absent-error` | `reset`;
4
4
  export type SQLiteCoreAdapterOptions = {
5
5
  driver: SQLiteDriver;
@@ -8,6 +8,10 @@ export type SQLiteCoreAdapterOptions = {
8
8
  appliedTxPruneMaxRows?: number;
9
9
  appliedTxPruneMaxAgeSeconds?: number;
10
10
  pullSinceReloadThreshold?: number;
11
+ /** Milliseconds without renewal before a sync run loses cache access. */
12
+ cacheGenerationClaimTtlMs?: number;
13
+ /** Host clock, injectable for expiry histories. */
14
+ now?: () => number;
11
15
  };
12
16
  export type SQLitePullSinceResult<TKey extends string | number> = {
13
17
  latestRowVersion: number;
@@ -19,6 +23,7 @@ export type SQLitePullSinceResult<TKey extends string | number> = {
19
23
  deletedKeys: Array<TKey>;
20
24
  deltas: Array<ReplayableTxDelta<Record<string, unknown>, TKey>>;
21
25
  };
26
+ export declare const DEFAULT_CACHE_GENERATION_CLAIM_TTL_MS: number;
22
27
  /**
23
28
  * Default cap on retained `applied_tx` rows per collection. The log is a
24
29
  * replayable cache, so a bounded row count keeps SQLite files from growing
@@ -43,9 +48,21 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
43
48
  private readonly appliedTxPruneMaxAgeSeconds;
44
49
  private readonly pullSinceReloadThreshold;
45
50
  private readonly replacementBatchSize;
51
+ private readonly cacheGenerationClaimTtlMs;
52
+ private readonly now;
46
53
  private initialized;
47
54
  private readonly collectionTableCache;
48
55
  private readonly collectionTableLoads;
56
+ claimCacheGeneration(collectionId: string): Promise<PersistedCacheGenerationClaim>;
57
+ rotateCacheGeneration(collectionId: string, claimId: string, resetMetadata?: {
58
+ key: string;
59
+ value: unknown;
60
+ }, expectedStorageCollectionId?: string): Promise<PersistedCacheGenerationClaim>;
61
+ releaseCacheGenerationClaim(claimId: string): Promise<void>;
62
+ renewCacheGenerationClaim(storageCollectionId: string, claimId: string): Promise<number | undefined>;
63
+ private renewCacheGenerationClaimUnscheduled;
64
+ private collectRetiredCacheGenerations;
65
+ getCacheGenerationNow(): number;
49
66
  constructor(options: SQLiteCoreAdapterOptions);
50
67
  runInHydrationScope<T>(task: (adapter: HydrationPersistenceAdapter) => Promise<T>): Promise<T>;
51
68
  runInRegularScope<T>(task: (adapter: HydrationPersistenceAdapter) => Promise<T>): Promise<T>;
@@ -54,17 +71,20 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
54
71
  private resolveScheduler;
55
72
  private runInTransaction;
56
73
  private assertCurrentSchemaVersion;
57
- loadSubset(collectionId: string, options: LoadSubsetOptions, ctx?: {
74
+ private assertCacheGenerationReadClaim;
75
+ loadSubset(target: PersistedStorageTarget, options: LoadSubsetOptions, ctx?: {
58
76
  requiredIndexSignatures?: ReadonlyArray<string>;
77
+ cacheGenerationClaimId?: string;
59
78
  }): Promise<Array<{
60
79
  key: string | number;
61
80
  value: Record<string, unknown>;
62
81
  metadata?: unknown;
63
82
  }>>;
64
83
  private loadSubsetUnscheduled;
65
- loadResumeSnapshot(collectionId: string, ctx?: {
84
+ loadResumeSnapshot(target: PersistedStorageTarget, ctx?: {
66
85
  requiredIndexSignatures?: ReadonlyArray<string>;
67
86
  includeRows?: boolean;
87
+ cacheGenerationClaimId?: string;
68
88
  }): Promise<{
69
89
  rows: Array<{
70
90
  key: string | number;
@@ -82,29 +102,41 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
82
102
  resetEpoch: number;
83
103
  }>;
84
104
  private loadResumeSnapshotUnscheduled;
85
- applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void>;
86
- reconcileCommittedTx(collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor): Promise<ReconciledCommittedTx>;
105
+ applyCommittedTx(target: PersistedStorageTarget, tx: PersistedTx): Promise<void>;
106
+ reconcileCommittedTx(target: PersistedStorageTarget, tx: PersistedTx, anchor: CommittedTxAnchor): Promise<ReconciledCommittedTx>;
87
107
  private applyCommittedTxUnscheduled;
88
108
  private reconcileCommittedTxUnscheduled;
89
109
  private applyCommittedTxInternal;
90
- loadCollectionMetadata(collectionId: string): Promise<Array<{
110
+ loadCollectionMetadata(target: PersistedStorageTarget, ctx?: {
111
+ cacheGenerationClaimId?: string;
112
+ }): Promise<Array<{
91
113
  key: string;
92
114
  value: unknown;
93
115
  }>>;
94
116
  private loadCollectionMetadataUnscheduled;
95
- scanRows(collectionId: string, options?: PersistedRowScanOptions): Promise<Array<PersistedScannedRow>>;
117
+ scanRows(target: PersistedStorageTarget, options?: PersistedRowScanOptions, ctx?: {
118
+ cacheGenerationClaimId?: string;
119
+ }): Promise<Array<PersistedScannedRow>>;
96
120
  private scanRowsUnscheduled;
97
- ensureIndex(collectionId: string, signature: string, spec: PersistedIndexSpec): Promise<void>;
121
+ ensureIndex(target: PersistedStorageTarget, signature: string, spec: PersistedIndexSpec, ctx?: {
122
+ cacheGenerationClaimId?: string;
123
+ }): Promise<void>;
98
124
  private ensureIndexUnscheduled;
99
- markIndexRemoved(collectionId: string, signature: string): Promise<void>;
125
+ markIndexRemoved(target: PersistedStorageTarget, signature: string, ctx?: {
126
+ cacheGenerationClaimId?: string;
127
+ }): Promise<void>;
100
128
  private markIndexRemovedUnscheduled;
101
- reserveLeadershipTerm(collectionId: string, observedTerm: number): Promise<{
129
+ reserveLeadershipTerm(target: PersistedStorageTarget, observedTerm: number, ctx?: {
130
+ cacheGenerationClaimId?: string;
131
+ }): Promise<{
102
132
  latestTerm: number;
103
133
  latestSeq: number;
104
134
  latestRowVersion: number;
105
135
  }>;
106
136
  private reserveLeadershipTermUnscheduled;
107
- getStreamPosition(collectionId: string): Promise<{
137
+ getStreamPosition(target: PersistedStorageTarget, ctx?: {
138
+ cacheGenerationClaimId?: string;
139
+ }): Promise<{
108
140
  latestTerm: number;
109
141
  latestSeq: number;
110
142
  latestRowVersion: number;
@@ -113,7 +145,9 @@ export declare class SQLiteCorePersistenceAdapter implements PersistenceAdapter
113
145
  private readLatestRowVersion;
114
146
  private readStreamPosition;
115
147
  private readKeySetEvidence;
116
- pullSince(collectionId: string, fromRowVersion: number): Promise<SQLitePullSinceResult<string | number>>;
148
+ pullSince(target: PersistedStorageTarget, fromRowVersion: number, ctx?: {
149
+ cacheGenerationClaimId?: string;
150
+ }): Promise<SQLitePullSinceResult<string | number>>;
117
151
  private pullSinceUnscheduled;
118
152
  private loadSubsetInternal;
119
153
  private applyInMemoryWhere;
@@ -1,8 +1,10 @@
1
1
  import { LoadSubsetOptions } from '@tanstack/db';
2
2
  import { RemoteSubsetOwner } from './remote-subset-owner.js';
3
- import { ApplyCommittedTxResponse, ApplyLocalMutationsResponse, CommittedTxAnchor, HydrationPersistenceAdapter, PersistedCollectionCoordinator, PersistedIndexSpec, PersistedMutationEnvelope, PersistedTx, PersistenceAdapter, ProtocolEnvelope, PullSinceResponse, ReconcileCommittedTxResponse } from './persisted.js';
3
+ import { ApplyCommittedTxResponse, ApplyLocalMutationsResponse, CommittedTxAnchor, HydrationPersistenceAdapter, PersistedCollectionCoordinator, PersistedIndexSpec, PersistedMutationEnvelope, PersistedStorageTarget, PersistedTx, PersistenceAdapter, ProtocolEnvelope, PullSinceResponse, ReconcileCommittedTxResponse } from './persisted.js';
4
4
  type CoordinatorAdapter = Pick<PersistenceAdapter, `loadSubset` | `applyCommittedTx` | `reconcileCommittedTx` | `ensureIndex` | `runInRegularScope`> & {
5
- pullSince?: (collectionId: string, fromRowVersion: number) => Promise<{
5
+ pullSince?: (target: PersistedStorageTarget, fromRowVersion: number, ctx?: {
6
+ cacheGenerationClaimId?: string;
7
+ }) => Promise<{
6
8
  latestRowVersion: number;
7
9
  requiresFullReload: true;
8
10
  } | {
@@ -11,12 +13,16 @@ type CoordinatorAdapter = Pick<PersistenceAdapter, `loadSubset` | `applyCommitte
11
13
  changedKeys: Array<string | number>;
12
14
  deletedKeys: Array<string | number>;
13
15
  }>;
14
- getStreamPosition?: (collectionId: string) => Promise<{
16
+ getStreamPosition?: (target: PersistedStorageTarget, ctx?: {
17
+ cacheGenerationClaimId?: string;
18
+ }) => Promise<{
15
19
  latestTerm: number;
16
20
  latestSeq: number;
17
21
  latestRowVersion: number;
18
22
  }>;
19
- reserveLeadershipTerm?: (collectionId: string, observedTerm: number) => Promise<{
23
+ reserveLeadershipTerm?: (target: PersistedStorageTarget, observedTerm: number, ctx?: {
24
+ cacheGenerationClaimId?: string;
25
+ }) => Promise<{
20
26
  latestTerm: number;
21
27
  latestSeq: number;
22
28
  latestRowVersion: number;
@@ -35,6 +41,7 @@ export declare class BroadcastCollectionCoordinator implements PersistedCollecti
35
41
  private readonly coordinatorName;
36
42
  private defaultAdapter;
37
43
  private readonly collectionAdapters;
44
+ private readonly collectionClaimIds;
38
45
  private readonly remoteSubsetOwners;
39
46
  private readonly remoteSubsetIds;
40
47
  private readonly outboundRemoteSubsetAcquisitions;
@@ -61,7 +68,7 @@ export declare class BroadcastCollectionCoordinator implements PersistedCollecti
61
68
  */
62
69
  setAdapter(adapter: CoordinatorAdapter): void;
63
70
  /** Register the persistence adapter that owns one collection. */
64
- setAdapterForCollection(collectionId: string, adapter: CoordinatorAdapter): void;
71
+ setAdapterForCollection(collectionId: string, adapter: CoordinatorAdapter, cacheGenerationClaimId?: string): void;
65
72
  registerRemoteSubsetOwner(collectionId: string, owner: RemoteSubsetOwner): () => void;
66
73
  getNodeId(): string;
67
74
  subscribe(collectionId: string, onMessage: (message: ProtocolEnvelope<unknown>) => void): () => void;
@@ -70,12 +77,13 @@ export declare class BroadcastCollectionCoordinator implements PersistedCollecti
70
77
  ensureLeadership(collectionId: string): Promise<void>;
71
78
  requestEnsureRemoteSubset(collectionId: string, options: LoadSubsetOptions): Promise<void>;
72
79
  requestReleaseRemoteSubset(collectionId: string, options: LoadSubsetOptions): Promise<void>;
80
+ private scheduleRemoteSubsetReleaseRetry;
73
81
  private acquireRemoteSubset;
74
- requestEnsurePersistedIndex(collectionId: string, signature: string, spec: PersistedIndexSpec, scopedAdapter?: HydrationPersistenceAdapter, localEnsureCompleted?: boolean): Promise<void>;
75
- requestApplyLocalMutations(collectionId: string, mutations: Array<PersistedMutationEnvelope>): Promise<ApplyLocalMutationsResponse>;
82
+ requestEnsurePersistedIndex(collectionId: string, signature: string, spec: PersistedIndexSpec, scopedAdapter?: HydrationPersistenceAdapter, localEnsureCompleted?: boolean, cacheGenerationClaimId?: string): Promise<void>;
83
+ requestApplyLocalMutations(collectionId: string, mutations: Array<PersistedMutationEnvelope>, cacheGenerationClaimId?: string): Promise<ApplyLocalMutationsResponse>;
76
84
  requestApplyCommittedTx(collectionId: string, tx: PersistedTx, scopedAdapter?: HydrationPersistenceAdapter): Promise<ApplyCommittedTxResponse>;
77
85
  reconcileCommittedTx(collectionId: string, tx: PersistedTx, anchor: CommittedTxAnchor, scopedAdapter?: HydrationPersistenceAdapter): Promise<ReconcileCommittedTxResponse>;
78
- pullSince(collectionId: string, fromRowVersion: number, scopedAdapter?: HydrationPersistenceAdapter): Promise<PullSinceResponse>;
86
+ pullSince(collectionId: string, fromRowVersion: number, scopedAdapter?: HydrationPersistenceAdapter, cacheGenerationClaimId?: string): Promise<PullSinceResponse>;
79
87
  dispose(): void;
80
88
  private getOrCreateCollectionState;
81
89
  private ensureCollectionState;
@@ -1,8 +1,9 @@
1
1
  import { safeRandomUUID } from "@tanstack/db";
2
2
  import { isValidCommittedTxAnchor } from "./committed-tx-anchor.js";
3
- import { DuplicateRemoteSubsetOwnerError, InvalidPersistenceAdapterError, RetryableRemoteSubsetAcquisitionError, IndeterminateCommitError, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
3
+ import { DuplicateRemoteSubsetOwnerError, InvalidPersistenceAdapterError, RetryableRemoteSubsetAcquisitionError, IndeterminateCommitError, InvalidPersistedCollectionConfigError, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
4
4
  import { toTransportedLoadSubsetOptions, toProcessLocalLoadSubsetOptions } from "./remote-subset-wire.js";
5
5
  import { unloadRemoteSubsetOwner } from "./remote-subset-owner.js";
6
+ import { managedStorageTarget, eagerStorageTarget } from "./persisted.js";
6
7
  const HEARTBEAT_INTERVAL_MS = 3e3;
7
8
  const LEADERSHIP_RETRY_DELAY_MS = 200;
8
9
  const RPC_TIMEOUT_MS = 1e4;
@@ -21,6 +22,7 @@ class BroadcastCollectionCoordinator {
21
22
  constructor(options) {
22
23
  this.nodeId = safeRandomUUID();
23
24
  this.collectionAdapters = /* @__PURE__ */ new Map();
25
+ this.collectionClaimIds = /* @__PURE__ */ new Map();
24
26
  this.remoteSubsetOwners = /* @__PURE__ */ new Map();
25
27
  this.remoteSubsetIds = /* @__PURE__ */ new Map();
26
28
  this.outboundRemoteSubsetAcquisitions = /* @__PURE__ */ new Map();
@@ -76,9 +78,14 @@ class BroadcastCollectionCoordinator {
76
78
  this.defaultAdapter = adapter;
77
79
  }
78
80
  /** Register the persistence adapter that owns one collection. */
79
- setAdapterForCollection(collectionId, adapter) {
81
+ setAdapterForCollection(collectionId, adapter, cacheGenerationClaimId) {
80
82
  requireDurableElection(adapter);
81
83
  this.collectionAdapters.set(collectionId, adapter);
84
+ if (cacheGenerationClaimId) {
85
+ this.collectionClaimIds.set(collectionId, cacheGenerationClaimId);
86
+ } else {
87
+ this.collectionClaimIds.delete(collectionId);
88
+ }
82
89
  }
83
90
  registerRemoteSubsetOwner(collectionId, owner) {
84
91
  if (this.remoteSubsetOwners.has(collectionId)) {
@@ -222,10 +229,29 @@ class BroadcastCollectionCoordinator {
222
229
  }
223
230
  try {
224
231
  await acquisition.release;
232
+ } catch (error) {
233
+ this.scheduleRemoteSubsetReleaseRetry(acquisition, options);
234
+ throw error;
225
235
  } finally {
226
236
  acquisition.release = null;
227
237
  }
228
238
  }
239
+ scheduleRemoteSubsetReleaseRetry(acquisition, options) {
240
+ const key = remoteSubsetAcquisitionKey(
241
+ acquisition.collectionId,
242
+ acquisition.acquisitionId
243
+ );
244
+ if (this.isDisposed() || this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition || acquisition.retryTimer !== null) {
245
+ return;
246
+ }
247
+ acquisition.retryTimer = setTimeout(() => {
248
+ acquisition.retryTimer = null;
249
+ void this.requestReleaseRemoteSubset(
250
+ acquisition.collectionId,
251
+ options
252
+ ).catch(() => void 0);
253
+ }, RPC_RETRY_DELAY_MS);
254
+ }
229
255
  async acquireRemoteSubset(acquisition) {
230
256
  if (acquisition.inFlight) return acquisition.inFlight;
231
257
  const route = { localOwner: false };
@@ -285,7 +311,9 @@ class BroadcastCollectionCoordinator {
285
311
  try {
286
312
  await work;
287
313
  acquired = true;
288
- this.cancelRemoteSubsetReplayRetry(acquisition);
314
+ if (!acquisition.releaseRequested) {
315
+ this.cancelRemoteSubsetReplayRetry(acquisition);
316
+ }
289
317
  } finally {
290
318
  if (acquisition.inFlight === work) acquisition.inFlight = null;
291
319
  const current = this.collections.get(acquisition.collectionId);
@@ -302,21 +330,34 @@ class BroadcastCollectionCoordinator {
302
330
  }
303
331
  }
304
332
  }
305
- async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false) {
333
+ async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false, cacheGenerationClaimId) {
306
334
  if (this.isLeader(collectionId)) {
307
335
  if (localEnsureCompleted) return;
308
- await (scopedAdapter ?? this.requireAdapter(collectionId)).ensureIndex(
309
- collectionId,
310
- signature,
311
- spec
312
- );
336
+ const adapter = scopedAdapter ?? this.requireAdapter(collectionId);
337
+ if (cacheGenerationClaimId) {
338
+ await adapter.ensureIndex(
339
+ managedStorageTarget(collectionId, cacheGenerationClaimId),
340
+ signature,
341
+ spec,
342
+ {
343
+ cacheGenerationClaimId
344
+ }
345
+ );
346
+ } else {
347
+ await adapter.ensureIndex(
348
+ eagerStorageTarget(collectionId),
349
+ signature,
350
+ spec
351
+ );
352
+ }
313
353
  return;
314
354
  }
315
355
  const response = await this.sendRPC(collectionId, {
316
356
  type: `rpc:ensurePersistedIndex:req`,
317
357
  rpcId: safeRandomUUID(),
318
358
  signature,
319
- spec
359
+ spec,
360
+ cacheGenerationClaimId
320
361
  });
321
362
  if (!response.ok) {
322
363
  throw new Error(
@@ -324,7 +365,7 @@ class BroadcastCollectionCoordinator {
324
365
  );
325
366
  }
326
367
  }
327
- async requestApplyLocalMutations(collectionId, mutations) {
368
+ async requestApplyLocalMutations(collectionId, mutations, cacheGenerationClaimId) {
328
369
  const initialRoute = this.waitForInitialRoute(collectionId);
329
370
  if (initialRoute) await initialRoute;
330
371
  if (this.isLeader(collectionId)) {
@@ -332,14 +373,16 @@ class BroadcastCollectionCoordinator {
332
373
  type: `rpc:applyLocalMutations:req`,
333
374
  rpcId: safeRandomUUID(),
334
375
  envelopeId: safeRandomUUID(),
335
- mutations
376
+ mutations,
377
+ cacheGenerationClaimId
336
378
  });
337
379
  }
338
380
  return this.sendRPC(collectionId, {
339
381
  type: `rpc:applyLocalMutations:req`,
340
382
  rpcId: safeRandomUUID(),
341
383
  envelopeId: safeRandomUUID(),
342
- mutations
384
+ mutations,
385
+ cacheGenerationClaimId
343
386
  });
344
387
  }
345
388
  async requestApplyCommittedTx(collectionId, tx, scopedAdapter) {
@@ -380,14 +423,15 @@ class BroadcastCollectionCoordinator {
380
423
  }
381
424
  return response;
382
425
  }
383
- async pullSince(collectionId, fromRowVersion, scopedAdapter) {
426
+ async pullSince(collectionId, fromRowVersion, scopedAdapter, cacheGenerationClaimId) {
384
427
  if (this.isLeader(collectionId)) {
385
428
  return this.handlePullSince(
386
429
  collectionId,
387
430
  {
388
431
  type: `rpc:pullSince:req`,
389
432
  rpcId: safeRandomUUID(),
390
- fromRowVersion
433
+ fromRowVersion,
434
+ cacheGenerationClaimId
391
435
  },
392
436
  scopedAdapter
393
437
  );
@@ -395,7 +439,8 @@ class BroadcastCollectionCoordinator {
395
439
  return this.sendRPC(collectionId, {
396
440
  type: `rpc:pullSince:req`,
397
441
  rpcId: safeRandomUUID(),
398
- fromRowVersion
442
+ fromRowVersion,
443
+ cacheGenerationClaimId
399
444
  });
400
445
  }
401
446
  // -----------------------------------------------------------------------
@@ -428,6 +473,7 @@ class BroadcastCollectionCoordinator {
428
473
  this.channel.close();
429
474
  this.collections.clear();
430
475
  this.collectionAdapters.clear();
476
+ this.collectionClaimIds.clear();
431
477
  for (const collectionId of this.remoteSubsetOwners.keys()) {
432
478
  this.releaseInboundRemoteSubsetAcquisitions(collectionId);
433
479
  }
@@ -488,9 +534,15 @@ class BroadcastCollectionCoordinator {
488
534
  const reserveTerm = adapter.reserveLeadershipTerm;
489
535
  if (!reserveTerm)
490
536
  throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
491
- const pos = await this.withWriterLock(
492
- () => reserveTerm.call(adapter, collectionId, state.latestTerm)
493
- );
537
+ const pos = await this.withWriterLock(() => {
538
+ const claimId = this.collectionClaimIds.get(collectionId);
539
+ return reserveTerm.call(
540
+ adapter,
541
+ claimId ? managedStorageTarget(collectionId, claimId) : eagerStorageTarget(collectionId),
542
+ state.latestTerm,
543
+ { cacheGenerationClaimId: claimId }
544
+ );
545
+ });
494
546
  state.latestTerm = pos.latestTerm;
495
547
  state.latestSeq = pos.latestSeq;
496
548
  state.latestRowVersion = pos.latestRowVersion;
@@ -637,6 +689,7 @@ class BroadcastCollectionCoordinator {
637
689
  this.releaseLeadership(collectionId, state);
638
690
  this.collections.delete(collectionId);
639
691
  this.collectionAdapters.delete(collectionId);
692
+ this.collectionClaimIds.delete(collectionId);
640
693
  const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`;
641
694
  for (const key of this.appliedEnvelopes.keys()) {
642
695
  if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key);
@@ -1050,10 +1103,7 @@ class BroadcastCollectionCoordinator {
1050
1103
  acquisition.released = true;
1051
1104
  acquisition.release = (async () => {
1052
1105
  try {
1053
- await acquisition.load;
1054
- } catch {
1055
- }
1056
- try {
1106
+ await acquisition.admitted;
1057
1107
  if (acquisition.transferred) {
1058
1108
  await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options);
1059
1109
  }
@@ -1169,6 +1219,7 @@ class BroadcastCollectionCoordinator {
1169
1219
  owner: options.owner,
1170
1220
  options: options.options,
1171
1221
  load: Promise.resolve(),
1222
+ admitted: Promise.resolve(),
1172
1223
  transferred: false,
1173
1224
  released: false,
1174
1225
  terminalRelease: false,
@@ -1176,6 +1227,10 @@ class BroadcastCollectionCoordinator {
1176
1227
  };
1177
1228
  this.releasedRemoteSubsetAcquisitionTimes.delete(key);
1178
1229
  this.inboundRemoteSubsetAcquisitions.set(key, acquisition);
1230
+ let resolveAdmission;
1231
+ acquisition.admitted = new Promise((resolve) => {
1232
+ resolveAdmission = resolve;
1233
+ });
1179
1234
  let resolveLoad;
1180
1235
  let rejectLoad;
1181
1236
  acquisition.load = new Promise((resolve, reject) => {
@@ -1188,6 +1243,8 @@ class BroadcastCollectionCoordinator {
1188
1243
  void Promise.resolve(load).then(resolveLoad, rejectLoad);
1189
1244
  } catch (error) {
1190
1245
  rejectLoad(error);
1246
+ } finally {
1247
+ resolveAdmission();
1191
1248
  }
1192
1249
  try {
1193
1250
  await acquisition.load;
@@ -1212,10 +1269,23 @@ class BroadcastCollectionCoordinator {
1212
1269
  }
1213
1270
  }
1214
1271
  async handleEnsurePersistedIndex(collectionId, request) {
1215
- await this.withScheduledWriterLock(
1216
- collectionId,
1217
- (adapter) => adapter.ensureIndex(collectionId, request.signature, request.spec)
1218
- );
1272
+ await this.withScheduledWriterLock(collectionId, (adapter) => {
1273
+ if (request.cacheGenerationClaimId) {
1274
+ return adapter.ensureIndex(
1275
+ managedStorageTarget(collectionId, request.cacheGenerationClaimId),
1276
+ request.signature,
1277
+ request.spec,
1278
+ {
1279
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1280
+ }
1281
+ );
1282
+ }
1283
+ return adapter.ensureIndex(
1284
+ eagerStorageTarget(collectionId),
1285
+ request.signature,
1286
+ request.spec
1287
+ );
1288
+ });
1219
1289
  return {
1220
1290
  type: `rpc:ensurePersistedIndex:res`,
1221
1291
  rpcId: request.rpcId,
@@ -1244,6 +1314,11 @@ class BroadcastCollectionCoordinator {
1244
1314
  });
1245
1315
  }
1246
1316
  async applyLocalMutationsOnce(collectionId, request) {
1317
+ if (this.collectionClaimIds.has(collectionId) && !request.cacheGenerationClaimId) {
1318
+ throw new InvalidPersistedCollectionConfigError(
1319
+ `A managed local mutation requires a persisted cache claim`
1320
+ );
1321
+ }
1247
1322
  const state = this.collections.get(collectionId);
1248
1323
  if (!state || !state.isLeader) {
1249
1324
  return {
@@ -1269,6 +1344,7 @@ class BroadcastCollectionCoordinator {
1269
1344
  }
1270
1345
  const pendingTx = {
1271
1346
  txId: safeRandomUUID(),
1347
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
1272
1348
  mutations: request.mutations.map((m) => ({
1273
1349
  type: m.type,
1274
1350
  key: m.key,
@@ -1501,7 +1577,10 @@ class BroadcastCollectionCoordinator {
1501
1577
  if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
1502
1578
  try {
1503
1579
  const reconciled = await adapter.reconcileCommittedTx(
1504
- collectionId,
1580
+ candidate.cacheGenerationClaimId ? managedStorageTarget(
1581
+ collectionId,
1582
+ candidate.cacheGenerationClaimId
1583
+ ) : eagerStorageTarget(collectionId),
1505
1584
  candidate,
1506
1585
  request.anchor
1507
1586
  );
@@ -1577,7 +1656,10 @@ class BroadcastCollectionCoordinator {
1577
1656
  );
1578
1657
  if (tx === null) return null;
1579
1658
  try {
1580
- await adapter.applyCommittedTx(collectionId, tx);
1659
+ await adapter.applyCommittedTx(
1660
+ tx.cacheGenerationClaimId ? managedStorageTarget(collectionId, tx.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
1661
+ tx
1662
+ );
1581
1663
  } catch (error) {
1582
1664
  throw toPersistedCollectionDurabilityError(collectionId, error);
1583
1665
  }
@@ -1602,7 +1684,13 @@ class BroadcastCollectionCoordinator {
1602
1684
  requiresFullReload: true
1603
1685
  };
1604
1686
  }
1605
- const result = await adapter.pullSince(collectionId, request.fromRowVersion);
1687
+ const result = await adapter.pullSince(
1688
+ request.cacheGenerationClaimId ? managedStorageTarget(collectionId, request.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
1689
+ request.fromRowVersion,
1690
+ {
1691
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1692
+ }
1693
+ );
1606
1694
  if (result.requiresFullReload) {
1607
1695
  return {
1608
1696
  type: `rpc:pullSince:res`,