@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 CHANGED
@@ -291,13 +291,22 @@ separate from the accepted-transaction precedence rule.
291
291
  ### Atomic resume snapshots
292
292
 
293
293
  Persistence adapters implement
294
- `loadResumeSnapshot(collectionId, options)` to let a sync source certify a
294
+ `loadResumeSnapshot(target, options)` to let a sync source certify a
295
295
  persisted resume baseline. One call must read rows, collection metadata, stream
296
296
  position, reset epoch, and key-set evidence from the same atomic database
297
297
  snapshot. `includeRows: false` requests the same certification data without
298
298
  materializing rows; `requiredIndexSignatures` carries the indexes needed by a
299
299
  row-bearing snapshot.
300
300
 
301
+ The adapter's data methods require an explicit `PersistedStorageTarget`. Use
302
+ `{ kind: 'eager', collectionId }` for an eager Collection, or
303
+ `{ kind: 'managed', storageCollectionId, claimId }` for a claimed on-demand
304
+ persisted cache generation. A bare string ID is rejected. The managed target
305
+ checks the claim even after its generation has been collected from SQLite;
306
+ choosing an eager target explicitly may reuse the same ID. Claim and
307
+ rotation methods take the logical Collection ID; renewal takes the physical
308
+ storage ID and release takes the claim ID.
309
+
301
310
  `PersistedKeySetEvidence.status` has three states:
302
311
 
303
312
  - `consistent`: the persisted rows match the adapter's durable expected-key
@@ -5,6 +5,7 @@ const committedTxAnchor = require("./committed-tx-anchor.cjs");
5
5
  const errors = require("./errors.cjs");
6
6
  const remoteSubsetWire = require("./remote-subset-wire.cjs");
7
7
  const remoteSubsetOwner = require("./remote-subset-owner.cjs");
8
+ const persisted = require("./persisted.cjs");
8
9
  const HEARTBEAT_INTERVAL_MS = 3e3;
9
10
  const LEADERSHIP_RETRY_DELAY_MS = 200;
10
11
  const RPC_TIMEOUT_MS = 1e4;
@@ -23,6 +24,7 @@ class BroadcastCollectionCoordinator {
23
24
  constructor(options) {
24
25
  this.nodeId = db.safeRandomUUID();
25
26
  this.collectionAdapters = /* @__PURE__ */ new Map();
27
+ this.collectionClaimIds = /* @__PURE__ */ new Map();
26
28
  this.remoteSubsetOwners = /* @__PURE__ */ new Map();
27
29
  this.remoteSubsetIds = /* @__PURE__ */ new Map();
28
30
  this.outboundRemoteSubsetAcquisitions = /* @__PURE__ */ new Map();
@@ -78,9 +80,14 @@ class BroadcastCollectionCoordinator {
78
80
  this.defaultAdapter = adapter;
79
81
  }
80
82
  /** Register the persistence adapter that owns one collection. */
81
- setAdapterForCollection(collectionId, adapter) {
83
+ setAdapterForCollection(collectionId, adapter, cacheGenerationClaimId) {
82
84
  requireDurableElection(adapter);
83
85
  this.collectionAdapters.set(collectionId, adapter);
86
+ if (cacheGenerationClaimId) {
87
+ this.collectionClaimIds.set(collectionId, cacheGenerationClaimId);
88
+ } else {
89
+ this.collectionClaimIds.delete(collectionId);
90
+ }
84
91
  }
85
92
  registerRemoteSubsetOwner(collectionId, owner) {
86
93
  if (this.remoteSubsetOwners.has(collectionId)) {
@@ -224,10 +231,29 @@ class BroadcastCollectionCoordinator {
224
231
  }
225
232
  try {
226
233
  await acquisition.release;
234
+ } catch (error) {
235
+ this.scheduleRemoteSubsetReleaseRetry(acquisition, options);
236
+ throw error;
227
237
  } finally {
228
238
  acquisition.release = null;
229
239
  }
230
240
  }
241
+ scheduleRemoteSubsetReleaseRetry(acquisition, options) {
242
+ const key = remoteSubsetAcquisitionKey(
243
+ acquisition.collectionId,
244
+ acquisition.acquisitionId
245
+ );
246
+ if (this.isDisposed() || this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition || acquisition.retryTimer !== null) {
247
+ return;
248
+ }
249
+ acquisition.retryTimer = setTimeout(() => {
250
+ acquisition.retryTimer = null;
251
+ void this.requestReleaseRemoteSubset(
252
+ acquisition.collectionId,
253
+ options
254
+ ).catch(() => void 0);
255
+ }, RPC_RETRY_DELAY_MS);
256
+ }
231
257
  async acquireRemoteSubset(acquisition) {
232
258
  if (acquisition.inFlight) return acquisition.inFlight;
233
259
  const route = { localOwner: false };
@@ -287,7 +313,9 @@ class BroadcastCollectionCoordinator {
287
313
  try {
288
314
  await work;
289
315
  acquired = true;
290
- this.cancelRemoteSubsetReplayRetry(acquisition);
316
+ if (!acquisition.releaseRequested) {
317
+ this.cancelRemoteSubsetReplayRetry(acquisition);
318
+ }
291
319
  } finally {
292
320
  if (acquisition.inFlight === work) acquisition.inFlight = null;
293
321
  const current = this.collections.get(acquisition.collectionId);
@@ -304,21 +332,34 @@ class BroadcastCollectionCoordinator {
304
332
  }
305
333
  }
306
334
  }
307
- async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false) {
335
+ async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false, cacheGenerationClaimId) {
308
336
  if (this.isLeader(collectionId)) {
309
337
  if (localEnsureCompleted) return;
310
- await (scopedAdapter ?? this.requireAdapter(collectionId)).ensureIndex(
311
- collectionId,
312
- signature,
313
- spec
314
- );
338
+ const adapter = scopedAdapter ?? this.requireAdapter(collectionId);
339
+ if (cacheGenerationClaimId) {
340
+ await adapter.ensureIndex(
341
+ persisted.managedStorageTarget(collectionId, cacheGenerationClaimId),
342
+ signature,
343
+ spec,
344
+ {
345
+ cacheGenerationClaimId
346
+ }
347
+ );
348
+ } else {
349
+ await adapter.ensureIndex(
350
+ persisted.eagerStorageTarget(collectionId),
351
+ signature,
352
+ spec
353
+ );
354
+ }
315
355
  return;
316
356
  }
317
357
  const response = await this.sendRPC(collectionId, {
318
358
  type: `rpc:ensurePersistedIndex:req`,
319
359
  rpcId: db.safeRandomUUID(),
320
360
  signature,
321
- spec
361
+ spec,
362
+ cacheGenerationClaimId
322
363
  });
323
364
  if (!response.ok) {
324
365
  throw new Error(
@@ -326,7 +367,7 @@ class BroadcastCollectionCoordinator {
326
367
  );
327
368
  }
328
369
  }
329
- async requestApplyLocalMutations(collectionId, mutations) {
370
+ async requestApplyLocalMutations(collectionId, mutations, cacheGenerationClaimId) {
330
371
  const initialRoute = this.waitForInitialRoute(collectionId);
331
372
  if (initialRoute) await initialRoute;
332
373
  if (this.isLeader(collectionId)) {
@@ -334,14 +375,16 @@ class BroadcastCollectionCoordinator {
334
375
  type: `rpc:applyLocalMutations:req`,
335
376
  rpcId: db.safeRandomUUID(),
336
377
  envelopeId: db.safeRandomUUID(),
337
- mutations
378
+ mutations,
379
+ cacheGenerationClaimId
338
380
  });
339
381
  }
340
382
  return this.sendRPC(collectionId, {
341
383
  type: `rpc:applyLocalMutations:req`,
342
384
  rpcId: db.safeRandomUUID(),
343
385
  envelopeId: db.safeRandomUUID(),
344
- mutations
386
+ mutations,
387
+ cacheGenerationClaimId
345
388
  });
346
389
  }
347
390
  async requestApplyCommittedTx(collectionId, tx, scopedAdapter) {
@@ -382,14 +425,15 @@ class BroadcastCollectionCoordinator {
382
425
  }
383
426
  return response;
384
427
  }
385
- async pullSince(collectionId, fromRowVersion, scopedAdapter) {
428
+ async pullSince(collectionId, fromRowVersion, scopedAdapter, cacheGenerationClaimId) {
386
429
  if (this.isLeader(collectionId)) {
387
430
  return this.handlePullSince(
388
431
  collectionId,
389
432
  {
390
433
  type: `rpc:pullSince:req`,
391
434
  rpcId: db.safeRandomUUID(),
392
- fromRowVersion
435
+ fromRowVersion,
436
+ cacheGenerationClaimId
393
437
  },
394
438
  scopedAdapter
395
439
  );
@@ -397,7 +441,8 @@ class BroadcastCollectionCoordinator {
397
441
  return this.sendRPC(collectionId, {
398
442
  type: `rpc:pullSince:req`,
399
443
  rpcId: db.safeRandomUUID(),
400
- fromRowVersion
444
+ fromRowVersion,
445
+ cacheGenerationClaimId
401
446
  });
402
447
  }
403
448
  // -----------------------------------------------------------------------
@@ -430,6 +475,7 @@ class BroadcastCollectionCoordinator {
430
475
  this.channel.close();
431
476
  this.collections.clear();
432
477
  this.collectionAdapters.clear();
478
+ this.collectionClaimIds.clear();
433
479
  for (const collectionId of this.remoteSubsetOwners.keys()) {
434
480
  this.releaseInboundRemoteSubsetAcquisitions(collectionId);
435
481
  }
@@ -490,9 +536,15 @@ class BroadcastCollectionCoordinator {
490
536
  const reserveTerm = adapter.reserveLeadershipTerm;
491
537
  if (!reserveTerm)
492
538
  throw new errors.InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
493
- const pos = await this.withWriterLock(
494
- () => reserveTerm.call(adapter, collectionId, state.latestTerm)
495
- );
539
+ const pos = await this.withWriterLock(() => {
540
+ const claimId = this.collectionClaimIds.get(collectionId);
541
+ return reserveTerm.call(
542
+ adapter,
543
+ claimId ? persisted.managedStorageTarget(collectionId, claimId) : persisted.eagerStorageTarget(collectionId),
544
+ state.latestTerm,
545
+ { cacheGenerationClaimId: claimId }
546
+ );
547
+ });
496
548
  state.latestTerm = pos.latestTerm;
497
549
  state.latestSeq = pos.latestSeq;
498
550
  state.latestRowVersion = pos.latestRowVersion;
@@ -639,6 +691,7 @@ class BroadcastCollectionCoordinator {
639
691
  this.releaseLeadership(collectionId, state);
640
692
  this.collections.delete(collectionId);
641
693
  this.collectionAdapters.delete(collectionId);
694
+ this.collectionClaimIds.delete(collectionId);
642
695
  const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`;
643
696
  for (const key of this.appliedEnvelopes.keys()) {
644
697
  if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key);
@@ -1052,10 +1105,7 @@ class BroadcastCollectionCoordinator {
1052
1105
  acquisition.released = true;
1053
1106
  acquisition.release = (async () => {
1054
1107
  try {
1055
- await acquisition.load;
1056
- } catch {
1057
- }
1058
- try {
1108
+ await acquisition.admitted;
1059
1109
  if (acquisition.transferred) {
1060
1110
  await remoteSubsetOwner.unloadRemoteSubsetOwner(acquisition.owner, acquisition.options);
1061
1111
  }
@@ -1171,6 +1221,7 @@ class BroadcastCollectionCoordinator {
1171
1221
  owner: options.owner,
1172
1222
  options: options.options,
1173
1223
  load: Promise.resolve(),
1224
+ admitted: Promise.resolve(),
1174
1225
  transferred: false,
1175
1226
  released: false,
1176
1227
  terminalRelease: false,
@@ -1178,6 +1229,10 @@ class BroadcastCollectionCoordinator {
1178
1229
  };
1179
1230
  this.releasedRemoteSubsetAcquisitionTimes.delete(key);
1180
1231
  this.inboundRemoteSubsetAcquisitions.set(key, acquisition);
1232
+ let resolveAdmission;
1233
+ acquisition.admitted = new Promise((resolve) => {
1234
+ resolveAdmission = resolve;
1235
+ });
1181
1236
  let resolveLoad;
1182
1237
  let rejectLoad;
1183
1238
  acquisition.load = new Promise((resolve, reject) => {
@@ -1190,6 +1245,8 @@ class BroadcastCollectionCoordinator {
1190
1245
  void Promise.resolve(load).then(resolveLoad, rejectLoad);
1191
1246
  } catch (error) {
1192
1247
  rejectLoad(error);
1248
+ } finally {
1249
+ resolveAdmission();
1193
1250
  }
1194
1251
  try {
1195
1252
  await acquisition.load;
@@ -1214,10 +1271,23 @@ class BroadcastCollectionCoordinator {
1214
1271
  }
1215
1272
  }
1216
1273
  async handleEnsurePersistedIndex(collectionId, request) {
1217
- await this.withScheduledWriterLock(
1218
- collectionId,
1219
- (adapter) => adapter.ensureIndex(collectionId, request.signature, request.spec)
1220
- );
1274
+ await this.withScheduledWriterLock(collectionId, (adapter) => {
1275
+ if (request.cacheGenerationClaimId) {
1276
+ return adapter.ensureIndex(
1277
+ persisted.managedStorageTarget(collectionId, request.cacheGenerationClaimId),
1278
+ request.signature,
1279
+ request.spec,
1280
+ {
1281
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1282
+ }
1283
+ );
1284
+ }
1285
+ return adapter.ensureIndex(
1286
+ persisted.eagerStorageTarget(collectionId),
1287
+ request.signature,
1288
+ request.spec
1289
+ );
1290
+ });
1221
1291
  return {
1222
1292
  type: `rpc:ensurePersistedIndex:res`,
1223
1293
  rpcId: request.rpcId,
@@ -1246,6 +1316,11 @@ class BroadcastCollectionCoordinator {
1246
1316
  });
1247
1317
  }
1248
1318
  async applyLocalMutationsOnce(collectionId, request) {
1319
+ if (this.collectionClaimIds.has(collectionId) && !request.cacheGenerationClaimId) {
1320
+ throw new errors.InvalidPersistedCollectionConfigError(
1321
+ `A managed local mutation requires a persisted cache claim`
1322
+ );
1323
+ }
1249
1324
  const state = this.collections.get(collectionId);
1250
1325
  if (!state || !state.isLeader) {
1251
1326
  return {
@@ -1271,6 +1346,7 @@ class BroadcastCollectionCoordinator {
1271
1346
  }
1272
1347
  const pendingTx = {
1273
1348
  txId: db.safeRandomUUID(),
1349
+ cacheGenerationClaimId: request.cacheGenerationClaimId,
1274
1350
  mutations: request.mutations.map((m) => ({
1275
1351
  type: m.type,
1276
1352
  key: m.key,
@@ -1503,7 +1579,10 @@ class BroadcastCollectionCoordinator {
1503
1579
  if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
1504
1580
  try {
1505
1581
  const reconciled = await adapter.reconcileCommittedTx(
1506
- collectionId,
1582
+ candidate.cacheGenerationClaimId ? persisted.managedStorageTarget(
1583
+ collectionId,
1584
+ candidate.cacheGenerationClaimId
1585
+ ) : persisted.eagerStorageTarget(collectionId),
1507
1586
  candidate,
1508
1587
  request.anchor
1509
1588
  );
@@ -1579,7 +1658,10 @@ class BroadcastCollectionCoordinator {
1579
1658
  );
1580
1659
  if (tx === null) return null;
1581
1660
  try {
1582
- await adapter.applyCommittedTx(collectionId, tx);
1661
+ await adapter.applyCommittedTx(
1662
+ tx.cacheGenerationClaimId ? persisted.managedStorageTarget(collectionId, tx.cacheGenerationClaimId) : persisted.eagerStorageTarget(collectionId),
1663
+ tx
1664
+ );
1583
1665
  } catch (error) {
1584
1666
  throw errors.toPersistedCollectionDurabilityError(collectionId, error);
1585
1667
  }
@@ -1604,7 +1686,13 @@ class BroadcastCollectionCoordinator {
1604
1686
  requiresFullReload: true
1605
1687
  };
1606
1688
  }
1607
- const result = await adapter.pullSince(collectionId, request.fromRowVersion);
1689
+ const result = await adapter.pullSince(
1690
+ request.cacheGenerationClaimId ? persisted.managedStorageTarget(collectionId, request.cacheGenerationClaimId) : persisted.eagerStorageTarget(collectionId),
1691
+ request.fromRowVersion,
1692
+ {
1693
+ cacheGenerationClaimId: request.cacheGenerationClaimId
1694
+ }
1695
+ );
1608
1696
  if (result.requiresFullReload) {
1609
1697
  return {
1610
1698
  type: `rpc:pullSince:res`,