@tanstack/db-sqlite-persistence-core 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/db-sqlite-persistence-core",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "SQLite persisted collection core for TanStack DB",
5
5
  "author": "TanStack Team",
6
6
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "@standard-schema/spec": "^1.1.0",
43
- "@tanstack/db": "0.9.0"
43
+ "@tanstack/db": "0.9.2"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "typescript": ">=4.7"
package/src/persisted.ts CHANGED
@@ -352,6 +352,7 @@ export interface PersistedCollectionUtils extends UtilsRecord {
352
352
  mutations: Array<PendingMutation<Record<string, unknown>>>
353
353
  }) => Promise<void> | void
354
354
  getLeadershipState?: () => PersistedCollectionLeadershipState
355
+ /** Hydrate once without acquiring a new ongoing subset lease. */
355
356
  forceReloadSubset?: (options: LoadSubsetOptions) => Promise<void> | void
356
357
  }
357
358
 
@@ -707,18 +708,6 @@ function stableSerialize(value: unknown): string {
707
708
  return JSON.stringify(toStableSerializable(value) ?? null)
708
709
  }
709
710
 
710
- function normalizeSubsetOptionsForKey(
711
- options: LoadSubsetOptions,
712
- ): Record<string, unknown> {
713
- return {
714
- where: toStableSerializable(options.where),
715
- orderBy: toStableSerializable(options.orderBy),
716
- limit: options.limit,
717
- cursor: toStableSerializable(options.cursor),
718
- offset: options.offset,
719
- }
720
- }
721
-
722
711
  function normalizeSyncFnResult(result: void | (() => void) | SyncConfigRes) {
723
712
  if (typeof result === `function`) {
724
713
  return { cleanup: result } satisfies SyncConfigRes
@@ -802,7 +791,7 @@ class PersistedCollectionRuntime<
802
791
  BufferedSyncTransaction<T, TKey>
803
792
  > = []
804
793
  private readonly queuedTxCommitted: Array<TxCommitted> = []
805
- private readonly subscriptionIds = new WeakMap<object, string>()
794
+ private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
806
795
 
807
796
  private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
808
797
  null
@@ -816,15 +805,17 @@ class PersistedCollectionRuntime<
816
805
  private started = false
817
806
  private startupMetadataPromise: Promise<void> | null = null
818
807
  private startPromise: Promise<void> | null = null
808
+ private resumeBaselinePromise: Promise<void> | null = null
809
+ private lifecycleGeneration = 0
819
810
  private internalApplyDepth = 0
820
811
  private appliedReceiptSequence = 0
821
812
  private readonly pendingAppliedReceipts = new Map<number, Promise<void>>()
822
- private isHydrating = false
813
+ private hydratingGeneration: number | null = null
823
814
  private coordinatorUnsubscribe: (() => void) | null = null
824
815
  private indexAddedUnsubscribe: (() => void) | null = null
825
816
  private indexRemovedUnsubscribe: (() => void) | null = null
826
817
  private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
827
- private nextSubscriptionId = 0
818
+ private nextRequestId = 0
828
819
 
829
820
  private latestTerm = 0
830
821
  private latestSeq = 0
@@ -842,6 +833,8 @@ class PersistedCollectionRuntime<
842
833
  ) {}
843
834
 
844
835
  setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
836
+ this.advanceLifecycle()
837
+
845
838
  const commit = syncControls.commit
846
839
  this.syncControls = {
847
840
  ...syncControls,
@@ -881,7 +874,7 @@ class PersistedCollectionRuntime<
881
874
  }
882
875
 
883
876
  isHydratingNow(): boolean {
884
- return this.isHydrating
877
+ return this.hydratingGeneration === this.lifecycleGeneration
885
878
  }
886
879
 
887
880
  isApplyingInternally(): boolean {
@@ -911,20 +904,56 @@ class PersistedCollectionRuntime<
911
904
  return this.startPromise
912
905
  }
913
906
 
914
- this.startPromise = this.startInternal()
907
+ const lifecycleGeneration = this.lifecycleGeneration
908
+ this.startPromise = this.startInternal(lifecycleGeneration)
915
909
  return this.startPromise
916
910
  }
917
911
 
912
+ ensureResumeBaselineHydrated(): Promise<void> {
913
+ if (this.resumeBaselinePromise) {
914
+ return this.resumeBaselinePromise
915
+ }
916
+
917
+ const lifecycleGeneration = this.lifecycleGeneration
918
+ this.resumeBaselinePromise = (async () => {
919
+ await this.ensureStarted()
920
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
921
+ if (this.syncMode !== `on-demand`) return
922
+
923
+ await this.hydrateBaseline(lifecycleGeneration)
924
+ })()
925
+ return this.resumeBaselinePromise
926
+ }
927
+
928
+ private async hydrateBaseline(lifecycleGeneration: number): Promise<void> {
929
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
930
+
931
+ const baseline = {}
932
+ this.activeSubsets.set(this.getSubsetKey(baseline), baseline)
933
+ const appliedCursor = this.appliedReceiptSequence
934
+ await this.applyMutex.run(async () => {
935
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
936
+ await this.hydrateSubsetUnsafe(baseline, {
937
+ requestRemoteEnsure: false,
938
+ lifecycleGeneration,
939
+ })
940
+ })
941
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
942
+ await this.waitForAppliedReceiptsAfter(appliedCursor)
943
+ }
944
+
918
945
  async ensureStartupMetadataLoaded(): Promise<void> {
919
946
  if (this.startupMetadataPromise) {
920
947
  return this.startupMetadataPromise
921
948
  }
922
949
 
923
- this.startupMetadataPromise = this.loadStartupMetadataInternal()
950
+ const lifecycleGeneration = this.lifecycleGeneration
951
+ this.startupMetadataPromise =
952
+ this.loadStartupMetadataInternal(lifecycleGeneration)
924
953
  return this.startupMetadataPromise
925
954
  }
926
955
 
927
- private async startInternal(): Promise<void> {
956
+ private async startInternal(lifecycleGeneration: number): Promise<void> {
928
957
  if (this.started) {
929
958
  return
930
959
  }
@@ -932,28 +961,28 @@ class PersistedCollectionRuntime<
932
961
  this.started = true
933
962
 
934
963
  await this.ensureStartupMetadataLoaded()
964
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
935
965
 
936
966
  const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
937
967
  this.attachIndexLifecycleListeners()
938
968
  await this.bootstrapPersistedIndexes(indexBootstrapSnapshot)
969
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
939
970
 
940
971
  if (this.syncMode !== `on-demand`) {
941
- this.activeSubsets.set(this.getSubsetKey({}), {})
942
- const appliedCursor = this.appliedReceiptSequence
943
- await this.applyMutex.run(() =>
944
- this.hydrateSubsetUnsafe({}, { requestRemoteEnsure: false }),
945
- )
946
- await this.waitForAppliedReceiptsAfter(appliedCursor)
972
+ await this.hydrateBaseline(lifecycleGeneration)
947
973
  }
948
974
  }
949
975
 
950
- private async loadStartupMetadataInternal(): Promise<void> {
976
+ private async loadStartupMetadataInternal(
977
+ lifecycleGeneration: number,
978
+ ): Promise<void> {
951
979
  // Restore stream position from the database so that new mutations
952
980
  // don't collide with previously applied transactions.
953
981
  if (this.persistence.adapter.getStreamPosition) {
954
982
  const position = await this.persistence.adapter.getStreamPosition(
955
983
  this.collectionId,
956
984
  )
985
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
957
986
  this.observeStreamPosition(
958
987
  position.latestTerm,
959
988
  position.latestSeq,
@@ -961,11 +990,8 @@ class PersistedCollectionRuntime<
961
990
  )
962
991
  }
963
992
 
964
- await this.loadCollectionMetadataIntoCollection()
965
- }
966
-
967
- private async loadCollectionMetadataIntoCollection(): Promise<void> {
968
993
  const collectionMetadata = await this.loadCollectionMetadataSnapshot()
994
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
969
995
  this.replaceCollectionMetadataSnapshot(collectionMetadata)
970
996
  }
971
997
 
@@ -1018,14 +1044,17 @@ class PersistedCollectionRuntime<
1018
1044
  options: LoadSubsetOptions,
1019
1045
  upstreamLoadSubset?: LoadSubsetFn,
1020
1046
  ): Promise<void> {
1047
+ const lifecycleGeneration = this.lifecycleGeneration
1021
1048
  this.activeSubsets.set(this.getSubsetKey(options), options)
1022
1049
 
1023
1050
  const appliedCursor = this.appliedReceiptSequence
1024
1051
  await this.applyMutex.run(() =>
1025
1052
  this.hydrateSubsetUnsafe(options, {
1026
1053
  requestRemoteEnsure: this.mode === `sync-present`,
1054
+ lifecycleGeneration,
1027
1055
  }),
1028
1056
  )
1057
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1029
1058
  await this.waitForAppliedReceiptsAfter(appliedCursor)
1030
1059
 
1031
1060
  if (upstreamLoadSubset) {
@@ -1044,6 +1073,8 @@ class PersistedCollectionRuntime<
1044
1073
  }
1045
1074
  console.warn(`Failed to trigger remote subset load:`, error)
1046
1075
  this.queueRemoteSubsetEnsure(options)
1076
+ // Hydration remains readable, but it does not satisfy remote demand.
1077
+ throw error
1047
1078
  }
1048
1079
  }
1049
1080
  }
@@ -1058,9 +1089,13 @@ class PersistedCollectionRuntime<
1058
1089
  }
1059
1090
 
1060
1091
  async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
1061
- this.activeSubsets.set(this.getSubsetKey(options), options)
1092
+ const lifecycleGeneration = this.lifecycleGeneration
1093
+ // A one-shot refresh does not acquire an enduring subscription lease.
1062
1094
  await this.applyMutex.run(() =>
1063
- this.hydrateSubsetUnsafe(options, { requestRemoteEnsure: false }),
1095
+ this.hydrateSubsetUnsafe(options, {
1096
+ requestRemoteEnsure: false,
1097
+ lifecycleGeneration,
1098
+ }),
1064
1099
  )
1065
1100
  }
1066
1101
 
@@ -1179,6 +1214,8 @@ class PersistedCollectionRuntime<
1179
1214
  }
1180
1215
 
1181
1216
  cleanup(): void {
1217
+ this.advanceLifecycle()
1218
+
1182
1219
  this.coordinatorUnsubscribe?.()
1183
1220
  this.coordinatorUnsubscribe = null
1184
1221
 
@@ -1201,6 +1238,15 @@ class PersistedCollectionRuntime<
1201
1238
  this.queuedHydrationTransactions.length = 0
1202
1239
  this.queuedTxCommitted.length = 0
1203
1240
  this.clearSyncControls()
1241
+ this.collection = null
1242
+ }
1243
+
1244
+ private advanceLifecycle(): void {
1245
+ this.lifecycleGeneration++
1246
+ this.started = false
1247
+ this.startupMetadataPromise = null
1248
+ this.startPromise = null
1249
+ this.resumeBaselinePromise = null
1204
1250
  }
1205
1251
 
1206
1252
  private withInternalApply<TResult>(task: () => TResult): TResult {
@@ -1253,15 +1299,19 @@ class PersistedCollectionRuntime<
1253
1299
  options: LoadSubsetOptions,
1254
1300
  config: {
1255
1301
  requestRemoteEnsure: boolean
1302
+ lifecycleGeneration: number
1256
1303
  },
1257
1304
  ): Promise<void> {
1258
- this.isHydrating = true
1305
+ this.hydratingGeneration = config.lifecycleGeneration
1259
1306
  try {
1260
1307
  const rows = await this.loadSubsetRowsUnsafe(options)
1308
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
1261
1309
 
1262
1310
  this.applyRowsToCollection(rows)
1263
1311
  } finally {
1264
- this.isHydrating = false
1312
+ if (this.hydratingGeneration === config.lifecycleGeneration) {
1313
+ this.hydratingGeneration = null
1314
+ }
1265
1315
  }
1266
1316
 
1267
1317
  await this.flushQueuedHydrationTransactionsUnsafe()
@@ -1808,20 +1858,14 @@ class PersistedCollectionRuntime<
1808
1858
  }
1809
1859
 
1810
1860
  private getSubsetKey(options: LoadSubsetOptions): string {
1811
- const subscription = options.subscription as object | undefined
1812
- if (subscription && typeof subscription === `object`) {
1813
- const existingId = this.subscriptionIds.get(subscription)
1814
- if (existingId) {
1815
- return existingId
1816
- }
1817
-
1818
- this.nextSubscriptionId++
1819
- const id = `sub:${this.nextSubscriptionId}`
1820
- this.subscriptionIds.set(subscription, id)
1821
- return id
1861
+ // A subscription can own several independent acquisitions, including
1862
+ // identical requests. Only releasing this options object ends its lease.
1863
+ let id = this.requestIds.get(options)
1864
+ if (id === undefined) {
1865
+ id = `request:${++this.nextRequestId}`
1866
+ this.requestIds.set(options, id)
1822
1867
  }
1823
-
1824
- return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
1868
+ return id
1825
1869
  }
1826
1870
 
1827
1871
  private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
@@ -1911,7 +1955,7 @@ class PersistedCollectionRuntime<
1911
1955
  // of both local and remote mutations. The seq dedup in
1912
1956
  // processCommittedTxUnsafe prevents double-processing of our own writes.
1913
1957
  if (isTxCommittedPayload(payload)) {
1914
- if (this.isHydrating) {
1958
+ if (this.isHydratingNow()) {
1915
1959
  this.queuedTxCommitted.push(payload)
1916
1960
  return
1917
1961
  }
@@ -2136,17 +2180,20 @@ class PersistedCollectionRuntime<
2136
2180
  }
2137
2181
 
2138
2182
  private async reloadActiveSubsetsUnsafe(): Promise<void> {
2183
+ const lifecycleGeneration = this.lifecycleGeneration
2139
2184
  const activeSubsetOptions =
2140
2185
  this.activeSubsets.size > 0
2141
2186
  ? Array.from(this.activeSubsets.values())
2142
2187
  : [{}]
2143
2188
 
2144
- this.isHydrating = true
2189
+ this.hydratingGeneration = lifecycleGeneration
2145
2190
  try {
2146
2191
  const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
2147
2192
  const collectionMetadata = await this.loadCollectionMetadataSnapshot()
2193
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2148
2194
  for (const options of activeSubsetOptions) {
2149
2195
  const subsetRows = await this.loadSubsetRowsUnsafe(options)
2196
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2150
2197
  for (const row of subsetRows) {
2151
2198
  mergedRows.set(row.key, {
2152
2199
  value: row.value,
@@ -2164,7 +2211,9 @@ class PersistedCollectionRuntime<
2164
2211
  collectionMetadata,
2165
2212
  )
2166
2213
  } finally {
2167
- this.isHydrating = false
2214
+ if (this.hydratingGeneration === lifecycleGeneration) {
2215
+ this.hydratingGeneration = null
2216
+ }
2168
2217
  }
2169
2218
 
2170
2219
  await this.flushQueuedHydrationTransactionsUnsafe()
@@ -2280,6 +2329,7 @@ function createWrappedSyncConfig<
2280
2329
  const getOpenTransaction = () =>
2281
2330
  transactionStack[transactionStack.length - 1]
2282
2331
  let fullStartPromise: Promise<void> | null = null
2332
+ const startupState = { cleanedUp: false }
2283
2333
  const acquisitions = new Map<LoadSubsetOptions, { forwarded: boolean }>()
2284
2334
  runtime.setSyncControls({
2285
2335
  begin: params.begin,
@@ -2295,11 +2345,14 @@ function createWrappedSyncConfig<
2295
2345
  const wrappedParams = {
2296
2346
  ...params,
2297
2347
  markReady: () => {
2348
+ if (startupState.cleanedUp) return
2298
2349
  void (fullStartPromise ?? runtime.ensureStarted())
2299
2350
  .then(() => {
2351
+ if (startupState.cleanedUp) return
2300
2352
  params.markReady()
2301
2353
  })
2302
2354
  .catch((error) => {
2355
+ if (startupState.cleanedUp) return
2303
2356
  console.warn(
2304
2357
  `Failed persisted sync startup before markReady:`,
2305
2358
  error,
@@ -2308,6 +2361,7 @@ function createWrappedSyncConfig<
2308
2361
  })
2309
2362
  },
2310
2363
  begin: (options?: { immediate?: boolean }) => {
2364
+ if (startupState.cleanedUp) return
2311
2365
  const transaction: OpenSyncTransaction<T, TKey> = {
2312
2366
  operations: [],
2313
2367
  rowMetadataWrites: new Map(),
@@ -2324,6 +2378,7 @@ function createWrappedSyncConfig<
2324
2378
  }
2325
2379
  },
2326
2380
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
2381
+ if (startupState.cleanedUp) return
2327
2382
  const normalization = runtime.normalizeSyncWriteMessage(message)
2328
2383
  const openTransaction = getOpenTransaction()
2329
2384
 
@@ -2369,7 +2424,12 @@ function createWrappedSyncConfig<
2369
2424
  metadata: params.metadata
2370
2425
  ? {
2371
2426
  row: {
2427
+ whenHydrated: () =>
2428
+ startupState.cleanedUp
2429
+ ? Promise.resolve()
2430
+ : runtime.ensureResumeBaselineHydrated(),
2372
2431
  get: (key: TKey) => {
2432
+ if (startupState.cleanedUp) return undefined
2373
2433
  const openTransaction = getOpenTransaction()
2374
2434
  const pendingWrite =
2375
2435
  openTransaction?.rowMetadataWrites.get(key)
@@ -2384,8 +2444,11 @@ function createWrappedSyncConfig<
2384
2444
  return params.metadata!.row.get(key)
2385
2445
  },
2386
2446
  scanPersisted: (options?: PersistedRowScanOptions) =>
2387
- runtime.scanPersistedRows(options),
2447
+ startupState.cleanedUp
2448
+ ? Promise.resolve([])
2449
+ : runtime.scanPersistedRows(options),
2388
2450
  set: (key: TKey, value: unknown) => {
2451
+ if (startupState.cleanedUp) return
2389
2452
  const openTransaction = getOpenTransaction()
2390
2453
  if (!openTransaction) {
2391
2454
  throw new InvalidPersistedCollectionConfigError(
@@ -2401,6 +2464,7 @@ function createWrappedSyncConfig<
2401
2464
  }
2402
2465
  },
2403
2466
  delete: (key: TKey) => {
2467
+ if (startupState.cleanedUp) return
2404
2468
  const openTransaction = getOpenTransaction()
2405
2469
  if (!openTransaction) {
2406
2470
  throw new InvalidPersistedCollectionConfigError(
@@ -2417,6 +2481,7 @@ function createWrappedSyncConfig<
2417
2481
  },
2418
2482
  collection: {
2419
2483
  get: (key: string) => {
2484
+ if (startupState.cleanedUp) return undefined
2420
2485
  const openTransaction = getOpenTransaction()
2421
2486
  const pendingWrite =
2422
2487
  openTransaction?.collectionMetadataWrites.get(key)
@@ -2428,6 +2493,7 @@ function createWrappedSyncConfig<
2428
2493
  return params.metadata!.collection.get(key)
2429
2494
  },
2430
2495
  set: (key: string, value: unknown) => {
2496
+ if (startupState.cleanedUp) return
2431
2497
  const openTransaction = getOpenTransaction()
2432
2498
  if (!openTransaction) {
2433
2499
  throw new InvalidPersistedCollectionConfigError(
@@ -2443,6 +2509,7 @@ function createWrappedSyncConfig<
2443
2509
  }
2444
2510
  },
2445
2511
  delete: (key: string) => {
2512
+ if (startupState.cleanedUp) return
2446
2513
  const openTransaction = getOpenTransaction()
2447
2514
  if (!openTransaction) {
2448
2515
  throw new InvalidPersistedCollectionConfigError(
@@ -2457,6 +2524,7 @@ function createWrappedSyncConfig<
2457
2524
  }
2458
2525
  },
2459
2526
  list: (prefix?: string) => {
2527
+ if (startupState.cleanedUp) return []
2460
2528
  const merged = new Map(
2461
2529
  params
2462
2530
  .metadata!.collection.list()
@@ -2487,6 +2555,7 @@ function createWrappedSyncConfig<
2487
2555
  }
2488
2556
  : undefined,
2489
2557
  truncate: () => {
2558
+ if (startupState.cleanedUp) return
2490
2559
  const openTransaction = getOpenTransaction()
2491
2560
  if (!openTransaction) {
2492
2561
  params.truncate()
@@ -2505,6 +2574,7 @@ function createWrappedSyncConfig<
2505
2574
  }
2506
2575
  },
2507
2576
  commit: (signal?: AbortSignal) => {
2577
+ if (startupState.cleanedUp) return true
2508
2578
  const openTransaction = transactionStack.pop()
2509
2579
  if (!openTransaction) {
2510
2580
  return params.commit(signal)
@@ -2559,7 +2629,6 @@ function createWrappedSyncConfig<
2559
2629
  }
2560
2630
 
2561
2631
  let sourceResult: SyncConfigRes = {}
2562
- const startupState = { cleanedUp: false }
2563
2632
  fullStartPromise = runtime.ensureStarted()
2564
2633
  const sourceResultPromise = (async () => {
2565
2634
  await runtime.ensureStartupMetadataLoaded()
@@ -2605,6 +2674,8 @@ function createWrappedSyncConfig<
2605
2674
  if (!resolvedSourceResult.loadSubset) return true
2606
2675
  acquisition.forwarded = true
2607
2676
  try {
2677
+ // Returning a promise transfers its lease even if it rejects.
2678
+ // Only a synchronous throw leaves no upstream lease to release.
2608
2679
  return resolvedSourceResult.loadSubset(loadOptions)
2609
2680
  } catch (error) {
2610
2681
  acquisition.forwarded = false