@tanstack/db-sqlite-persistence-core 0.2.20 → 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.20",
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.8.7"
43
+ "@tanstack/db": "0.9.2"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "typescript": ">=4.7"
package/src/persisted.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  CollectionIndexMetadata,
22
22
  DeleteMutationFnParams,
23
23
  InsertMutationFnParams,
24
+ LoadSubsetFn,
24
25
  LoadSubsetOptions,
25
26
  PendingMutation,
26
27
  SyncAppliedReceipt,
@@ -351,6 +352,7 @@ export interface PersistedCollectionUtils extends UtilsRecord {
351
352
  mutations: Array<PendingMutation<Record<string, unknown>>>
352
353
  }) => Promise<void> | void
353
354
  getLeadershipState?: () => PersistedCollectionLeadershipState
355
+ /** Hydrate once without acquiring a new ongoing subset lease. */
354
356
  forceReloadSubset?: (options: LoadSubsetOptions) => Promise<void> | void
355
357
  }
356
358
 
@@ -706,18 +708,6 @@ function stableSerialize(value: unknown): string {
706
708
  return JSON.stringify(toStableSerializable(value) ?? null)
707
709
  }
708
710
 
709
- function normalizeSubsetOptionsForKey(
710
- options: LoadSubsetOptions,
711
- ): Record<string, unknown> {
712
- return {
713
- where: toStableSerializable(options.where),
714
- orderBy: toStableSerializable(options.orderBy),
715
- limit: options.limit,
716
- cursor: toStableSerializable(options.cursor),
717
- offset: options.offset,
718
- }
719
- }
720
-
721
711
  function normalizeSyncFnResult(result: void | (() => void) | SyncConfigRes) {
722
712
  if (typeof result === `function`) {
723
713
  return { cleanup: result } satisfies SyncConfigRes
@@ -801,7 +791,7 @@ class PersistedCollectionRuntime<
801
791
  BufferedSyncTransaction<T, TKey>
802
792
  > = []
803
793
  private readonly queuedTxCommitted: Array<TxCommitted> = []
804
- private readonly subscriptionIds = new WeakMap<object, string>()
794
+ private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
805
795
 
806
796
  private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
807
797
  null
@@ -815,15 +805,17 @@ class PersistedCollectionRuntime<
815
805
  private started = false
816
806
  private startupMetadataPromise: Promise<void> | null = null
817
807
  private startPromise: Promise<void> | null = null
808
+ private resumeBaselinePromise: Promise<void> | null = null
809
+ private lifecycleGeneration = 0
818
810
  private internalApplyDepth = 0
819
811
  private appliedReceiptSequence = 0
820
812
  private readonly pendingAppliedReceipts = new Map<number, Promise<void>>()
821
- private isHydrating = false
813
+ private hydratingGeneration: number | null = null
822
814
  private coordinatorUnsubscribe: (() => void) | null = null
823
815
  private indexAddedUnsubscribe: (() => void) | null = null
824
816
  private indexRemovedUnsubscribe: (() => void) | null = null
825
817
  private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
826
- private nextSubscriptionId = 0
818
+ private nextRequestId = 0
827
819
 
828
820
  private latestTerm = 0
829
821
  private latestSeq = 0
@@ -841,6 +833,8 @@ class PersistedCollectionRuntime<
841
833
  ) {}
842
834
 
843
835
  setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
836
+ this.advanceLifecycle()
837
+
844
838
  const commit = syncControls.commit
845
839
  this.syncControls = {
846
840
  ...syncControls,
@@ -880,7 +874,7 @@ class PersistedCollectionRuntime<
880
874
  }
881
875
 
882
876
  isHydratingNow(): boolean {
883
- return this.isHydrating
877
+ return this.hydratingGeneration === this.lifecycleGeneration
884
878
  }
885
879
 
886
880
  isApplyingInternally(): boolean {
@@ -910,20 +904,56 @@ class PersistedCollectionRuntime<
910
904
  return this.startPromise
911
905
  }
912
906
 
913
- this.startPromise = this.startInternal()
907
+ const lifecycleGeneration = this.lifecycleGeneration
908
+ this.startPromise = this.startInternal(lifecycleGeneration)
914
909
  return this.startPromise
915
910
  }
916
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
+
917
945
  async ensureStartupMetadataLoaded(): Promise<void> {
918
946
  if (this.startupMetadataPromise) {
919
947
  return this.startupMetadataPromise
920
948
  }
921
949
 
922
- this.startupMetadataPromise = this.loadStartupMetadataInternal()
950
+ const lifecycleGeneration = this.lifecycleGeneration
951
+ this.startupMetadataPromise =
952
+ this.loadStartupMetadataInternal(lifecycleGeneration)
923
953
  return this.startupMetadataPromise
924
954
  }
925
955
 
926
- private async startInternal(): Promise<void> {
956
+ private async startInternal(lifecycleGeneration: number): Promise<void> {
927
957
  if (this.started) {
928
958
  return
929
959
  }
@@ -931,28 +961,28 @@ class PersistedCollectionRuntime<
931
961
  this.started = true
932
962
 
933
963
  await this.ensureStartupMetadataLoaded()
964
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
934
965
 
935
966
  const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
936
967
  this.attachIndexLifecycleListeners()
937
968
  await this.bootstrapPersistedIndexes(indexBootstrapSnapshot)
969
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
938
970
 
939
971
  if (this.syncMode !== `on-demand`) {
940
- this.activeSubsets.set(this.getSubsetKey({}), {})
941
- const appliedCursor = this.appliedReceiptSequence
942
- await this.applyMutex.run(() =>
943
- this.hydrateSubsetUnsafe({}, { requestRemoteEnsure: false }),
944
- )
945
- await this.waitForAppliedReceiptsAfter(appliedCursor)
972
+ await this.hydrateBaseline(lifecycleGeneration)
946
973
  }
947
974
  }
948
975
 
949
- private async loadStartupMetadataInternal(): Promise<void> {
976
+ private async loadStartupMetadataInternal(
977
+ lifecycleGeneration: number,
978
+ ): Promise<void> {
950
979
  // Restore stream position from the database so that new mutations
951
980
  // don't collide with previously applied transactions.
952
981
  if (this.persistence.adapter.getStreamPosition) {
953
982
  const position = await this.persistence.adapter.getStreamPosition(
954
983
  this.collectionId,
955
984
  )
985
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
956
986
  this.observeStreamPosition(
957
987
  position.latestTerm,
958
988
  position.latestSeq,
@@ -960,11 +990,8 @@ class PersistedCollectionRuntime<
960
990
  )
961
991
  }
962
992
 
963
- await this.loadCollectionMetadataIntoCollection()
964
- }
965
-
966
- private async loadCollectionMetadataIntoCollection(): Promise<void> {
967
993
  const collectionMetadata = await this.loadCollectionMetadataSnapshot()
994
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
968
995
  this.replaceCollectionMetadataSnapshot(collectionMetadata)
969
996
  }
970
997
 
@@ -1015,33 +1042,39 @@ class PersistedCollectionRuntime<
1015
1042
 
1016
1043
  async loadSubset(
1017
1044
  options: LoadSubsetOptions,
1018
- upstreamLoadSubset?: (options: LoadSubsetOptions) => true | Promise<void>,
1045
+ upstreamLoadSubset?: LoadSubsetFn,
1019
1046
  ): Promise<void> {
1047
+ const lifecycleGeneration = this.lifecycleGeneration
1020
1048
  this.activeSubsets.set(this.getSubsetKey(options), options)
1021
1049
 
1022
1050
  const appliedCursor = this.appliedReceiptSequence
1023
1051
  await this.applyMutex.run(() =>
1024
1052
  this.hydrateSubsetUnsafe(options, {
1025
1053
  requestRemoteEnsure: this.mode === `sync-present`,
1054
+ lifecycleGeneration,
1026
1055
  }),
1027
1056
  )
1057
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1028
1058
  await this.waitForAppliedReceiptsAfter(appliedCursor)
1029
1059
 
1030
1060
  if (upstreamLoadSubset) {
1031
1061
  try {
1032
- const maybePromise = upstreamLoadSubset(options)
1033
- if (maybePromise instanceof Promise) {
1034
- await maybePromise.catch((error) => {
1035
- console.warn(
1036
- `Failed to load remote subset in persisted wrapper:`,
1037
- error,
1038
- )
1039
- this.queueRemoteSubsetEnsure(options)
1040
- })
1041
- }
1062
+ await upstreamLoadSubset(options)
1042
1063
  } catch (error) {
1064
+ if (
1065
+ options.signal?.aborted ||
1066
+ (typeof error === `object` &&
1067
+ error !== null &&
1068
+ `name` in error &&
1069
+ error.name === `AbortError`)
1070
+ ) {
1071
+ this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1072
+ throw error
1073
+ }
1043
1074
  console.warn(`Failed to trigger remote subset load:`, error)
1044
1075
  this.queueRemoteSubsetEnsure(options)
1076
+ // Hydration remains readable, but it does not satisfy remote demand.
1077
+ throw error
1045
1078
  }
1046
1079
  }
1047
1080
  }
@@ -1051,13 +1084,18 @@ class PersistedCollectionRuntime<
1051
1084
  upstreamUnloadSubset?: (options: LoadSubsetOptions) => void,
1052
1085
  ): void {
1053
1086
  this.activeSubsets.delete(this.getSubsetKey(options))
1087
+ this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1054
1088
  upstreamUnloadSubset?.(options)
1055
1089
  }
1056
1090
 
1057
1091
  async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
1058
- this.activeSubsets.set(this.getSubsetKey(options), options)
1092
+ const lifecycleGeneration = this.lifecycleGeneration
1093
+ // A one-shot refresh does not acquire an enduring subscription lease.
1059
1094
  await this.applyMutex.run(() =>
1060
- this.hydrateSubsetUnsafe(options, { requestRemoteEnsure: false }),
1095
+ this.hydrateSubsetUnsafe(options, {
1096
+ requestRemoteEnsure: false,
1097
+ lifecycleGeneration,
1098
+ }),
1061
1099
  )
1062
1100
  }
1063
1101
 
@@ -1176,6 +1214,8 @@ class PersistedCollectionRuntime<
1176
1214
  }
1177
1215
 
1178
1216
  cleanup(): void {
1217
+ this.advanceLifecycle()
1218
+
1179
1219
  this.coordinatorUnsubscribe?.()
1180
1220
  this.coordinatorUnsubscribe = null
1181
1221
 
@@ -1198,6 +1238,15 @@ class PersistedCollectionRuntime<
1198
1238
  this.queuedHydrationTransactions.length = 0
1199
1239
  this.queuedTxCommitted.length = 0
1200
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
1201
1250
  }
1202
1251
 
1203
1252
  private withInternalApply<TResult>(task: () => TResult): TResult {
@@ -1250,15 +1299,19 @@ class PersistedCollectionRuntime<
1250
1299
  options: LoadSubsetOptions,
1251
1300
  config: {
1252
1301
  requestRemoteEnsure: boolean
1302
+ lifecycleGeneration: number
1253
1303
  },
1254
1304
  ): Promise<void> {
1255
- this.isHydrating = true
1305
+ this.hydratingGeneration = config.lifecycleGeneration
1256
1306
  try {
1257
1307
  const rows = await this.loadSubsetRowsUnsafe(options)
1308
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
1258
1309
 
1259
1310
  this.applyRowsToCollection(rows)
1260
1311
  } finally {
1261
- this.isHydrating = false
1312
+ if (this.hydratingGeneration === config.lifecycleGeneration) {
1313
+ this.hydratingGeneration = null
1314
+ }
1262
1315
  }
1263
1316
 
1264
1317
  await this.flushQueuedHydrationTransactionsUnsafe()
@@ -1805,26 +1858,21 @@ class PersistedCollectionRuntime<
1805
1858
  }
1806
1859
 
1807
1860
  private getSubsetKey(options: LoadSubsetOptions): string {
1808
- const subscription = options.subscription as object | undefined
1809
- if (subscription && typeof subscription === `object`) {
1810
- const existingId = this.subscriptionIds.get(subscription)
1811
- if (existingId) {
1812
- return existingId
1813
- }
1814
-
1815
- this.nextSubscriptionId++
1816
- const id = `sub:${this.nextSubscriptionId}`
1817
- this.subscriptionIds.set(subscription, id)
1818
- 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)
1819
1867
  }
1820
-
1821
- return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
1868
+ return id
1822
1869
  }
1823
1870
 
1824
1871
  private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
1825
1872
  if (
1826
1873
  this.mode !== `sync-present` ||
1827
- !this.persistence.coordinator.requestEnsureRemoteSubset
1874
+ !this.persistence.coordinator.requestEnsureRemoteSubset ||
1875
+ this.activeSubsets.get(this.getSubsetKey(options)) !== options
1828
1876
  ) {
1829
1877
  return
1830
1878
  }
@@ -1907,7 +1955,7 @@ class PersistedCollectionRuntime<
1907
1955
  // of both local and remote mutations. The seq dedup in
1908
1956
  // processCommittedTxUnsafe prevents double-processing of our own writes.
1909
1957
  if (isTxCommittedPayload(payload)) {
1910
- if (this.isHydrating) {
1958
+ if (this.isHydratingNow()) {
1911
1959
  this.queuedTxCommitted.push(payload)
1912
1960
  return
1913
1961
  }
@@ -2132,17 +2180,20 @@ class PersistedCollectionRuntime<
2132
2180
  }
2133
2181
 
2134
2182
  private async reloadActiveSubsetsUnsafe(): Promise<void> {
2183
+ const lifecycleGeneration = this.lifecycleGeneration
2135
2184
  const activeSubsetOptions =
2136
2185
  this.activeSubsets.size > 0
2137
2186
  ? Array.from(this.activeSubsets.values())
2138
2187
  : [{}]
2139
2188
 
2140
- this.isHydrating = true
2189
+ this.hydratingGeneration = lifecycleGeneration
2141
2190
  try {
2142
2191
  const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
2143
2192
  const collectionMetadata = await this.loadCollectionMetadataSnapshot()
2193
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2144
2194
  for (const options of activeSubsetOptions) {
2145
2195
  const subsetRows = await this.loadSubsetRowsUnsafe(options)
2196
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2146
2197
  for (const row of subsetRows) {
2147
2198
  mergedRows.set(row.key, {
2148
2199
  value: row.value,
@@ -2160,7 +2211,9 @@ class PersistedCollectionRuntime<
2160
2211
  collectionMetadata,
2161
2212
  )
2162
2213
  } finally {
2163
- this.isHydrating = false
2214
+ if (this.hydratingGeneration === lifecycleGeneration) {
2215
+ this.hydratingGeneration = null
2216
+ }
2164
2217
  }
2165
2218
 
2166
2219
  await this.flushQueuedHydrationTransactionsUnsafe()
@@ -2276,24 +2329,8 @@ function createWrappedSyncConfig<
2276
2329
  const getOpenTransaction = () =>
2277
2330
  transactionStack[transactionStack.length - 1]
2278
2331
  let fullStartPromise: Promise<void> | null = null
2279
- const cancelledLoadKeys = new Set<string>()
2280
- const loadSubscriptionIds = new WeakMap<object, string>()
2281
- let nextLoadSubscriptionId = 0
2282
- const getLoadKey = (options: LoadSubsetOptions) => {
2283
- const subscription = options.subscription as object | undefined
2284
- if (subscription && typeof subscription === `object`) {
2285
- const existingId = loadSubscriptionIds.get(subscription)
2286
- if (existingId) {
2287
- return `sub:${existingId}`
2288
- }
2289
- nextLoadSubscriptionId++
2290
- const nextId = String(nextLoadSubscriptionId)
2291
- loadSubscriptionIds.set(subscription, nextId)
2292
- return `sub:${nextId}`
2293
- }
2294
-
2295
- return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
2296
- }
2332
+ const startupState = { cleanedUp: false }
2333
+ const acquisitions = new Map<LoadSubsetOptions, { forwarded: boolean }>()
2297
2334
  runtime.setSyncControls({
2298
2335
  begin: params.begin,
2299
2336
  write: params.write as SyncControlFns<T, TKey>[`write`],
@@ -2308,11 +2345,14 @@ function createWrappedSyncConfig<
2308
2345
  const wrappedParams = {
2309
2346
  ...params,
2310
2347
  markReady: () => {
2348
+ if (startupState.cleanedUp) return
2311
2349
  void (fullStartPromise ?? runtime.ensureStarted())
2312
2350
  .then(() => {
2351
+ if (startupState.cleanedUp) return
2313
2352
  params.markReady()
2314
2353
  })
2315
2354
  .catch((error) => {
2355
+ if (startupState.cleanedUp) return
2316
2356
  console.warn(
2317
2357
  `Failed persisted sync startup before markReady:`,
2318
2358
  error,
@@ -2321,6 +2361,7 @@ function createWrappedSyncConfig<
2321
2361
  })
2322
2362
  },
2323
2363
  begin: (options?: { immediate?: boolean }) => {
2364
+ if (startupState.cleanedUp) return
2324
2365
  const transaction: OpenSyncTransaction<T, TKey> = {
2325
2366
  operations: [],
2326
2367
  rowMetadataWrites: new Map(),
@@ -2337,6 +2378,7 @@ function createWrappedSyncConfig<
2337
2378
  }
2338
2379
  },
2339
2380
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
2381
+ if (startupState.cleanedUp) return
2340
2382
  const normalization = runtime.normalizeSyncWriteMessage(message)
2341
2383
  const openTransaction = getOpenTransaction()
2342
2384
 
@@ -2382,7 +2424,12 @@ function createWrappedSyncConfig<
2382
2424
  metadata: params.metadata
2383
2425
  ? {
2384
2426
  row: {
2427
+ whenHydrated: () =>
2428
+ startupState.cleanedUp
2429
+ ? Promise.resolve()
2430
+ : runtime.ensureResumeBaselineHydrated(),
2385
2431
  get: (key: TKey) => {
2432
+ if (startupState.cleanedUp) return undefined
2386
2433
  const openTransaction = getOpenTransaction()
2387
2434
  const pendingWrite =
2388
2435
  openTransaction?.rowMetadataWrites.get(key)
@@ -2397,8 +2444,11 @@ function createWrappedSyncConfig<
2397
2444
  return params.metadata!.row.get(key)
2398
2445
  },
2399
2446
  scanPersisted: (options?: PersistedRowScanOptions) =>
2400
- runtime.scanPersistedRows(options),
2447
+ startupState.cleanedUp
2448
+ ? Promise.resolve([])
2449
+ : runtime.scanPersistedRows(options),
2401
2450
  set: (key: TKey, value: unknown) => {
2451
+ if (startupState.cleanedUp) return
2402
2452
  const openTransaction = getOpenTransaction()
2403
2453
  if (!openTransaction) {
2404
2454
  throw new InvalidPersistedCollectionConfigError(
@@ -2414,6 +2464,7 @@ function createWrappedSyncConfig<
2414
2464
  }
2415
2465
  },
2416
2466
  delete: (key: TKey) => {
2467
+ if (startupState.cleanedUp) return
2417
2468
  const openTransaction = getOpenTransaction()
2418
2469
  if (!openTransaction) {
2419
2470
  throw new InvalidPersistedCollectionConfigError(
@@ -2430,6 +2481,7 @@ function createWrappedSyncConfig<
2430
2481
  },
2431
2482
  collection: {
2432
2483
  get: (key: string) => {
2484
+ if (startupState.cleanedUp) return undefined
2433
2485
  const openTransaction = getOpenTransaction()
2434
2486
  const pendingWrite =
2435
2487
  openTransaction?.collectionMetadataWrites.get(key)
@@ -2441,6 +2493,7 @@ function createWrappedSyncConfig<
2441
2493
  return params.metadata!.collection.get(key)
2442
2494
  },
2443
2495
  set: (key: string, value: unknown) => {
2496
+ if (startupState.cleanedUp) return
2444
2497
  const openTransaction = getOpenTransaction()
2445
2498
  if (!openTransaction) {
2446
2499
  throw new InvalidPersistedCollectionConfigError(
@@ -2456,6 +2509,7 @@ function createWrappedSyncConfig<
2456
2509
  }
2457
2510
  },
2458
2511
  delete: (key: string) => {
2512
+ if (startupState.cleanedUp) return
2459
2513
  const openTransaction = getOpenTransaction()
2460
2514
  if (!openTransaction) {
2461
2515
  throw new InvalidPersistedCollectionConfigError(
@@ -2470,6 +2524,7 @@ function createWrappedSyncConfig<
2470
2524
  }
2471
2525
  },
2472
2526
  list: (prefix?: string) => {
2527
+ if (startupState.cleanedUp) return []
2473
2528
  const merged = new Map(
2474
2529
  params
2475
2530
  .metadata!.collection.list()
@@ -2500,6 +2555,7 @@ function createWrappedSyncConfig<
2500
2555
  }
2501
2556
  : undefined,
2502
2557
  truncate: () => {
2558
+ if (startupState.cleanedUp) return
2503
2559
  const openTransaction = getOpenTransaction()
2504
2560
  if (!openTransaction) {
2505
2561
  params.truncate()
@@ -2518,6 +2574,7 @@ function createWrappedSyncConfig<
2518
2574
  }
2519
2575
  },
2520
2576
  commit: (signal?: AbortSignal) => {
2577
+ if (startupState.cleanedUp) return true
2521
2578
  const openTransaction = transactionStack.pop()
2522
2579
  if (!openTransaction) {
2523
2580
  return params.commit(signal)
@@ -2572,7 +2629,6 @@ function createWrappedSyncConfig<
2572
2629
  }
2573
2630
 
2574
2631
  let sourceResult: SyncConfigRes = {}
2575
- const startupState = { cleanedUp: false }
2576
2632
  fullStartPromise = runtime.ensureStarted()
2577
2633
  const sourceResultPromise = (async () => {
2578
2634
  await runtime.ensureStartupMetadataLoaded()
@@ -2590,23 +2646,50 @@ function createWrappedSyncConfig<
2590
2646
  return {
2591
2647
  cleanup: () => {
2592
2648
  startupState.cleanedUp = true
2649
+ acquisitions.clear()
2593
2650
  sourceResult.cleanup?.()
2594
2651
  runtime.cleanup()
2595
2652
  runtime.clearSyncControls()
2596
2653
  },
2597
2654
  loadSubset: async (options: LoadSubsetOptions) => {
2598
- const loadKey = getLoadKey(options)
2599
- cancelledLoadKeys.delete(loadKey)
2655
+ const acquisition = { forwarded: false }
2656
+ acquisitions.set(options, acquisition)
2600
2657
  await fullStartPromise
2601
2658
  const resolvedSourceResult = await sourceResultPromise
2602
- if (startupState.cleanedUp || cancelledLoadKeys.has(loadKey)) {
2659
+ if (
2660
+ startupState.cleanedUp ||
2661
+ acquisitions.get(options) !== acquisition
2662
+ ) {
2603
2663
  return
2604
2664
  }
2605
- await runtime.loadSubset(options, resolvedSourceResult.loadSubset)
2665
+ return runtime.loadSubset(options, (loadOptions) => {
2666
+ // Hydration is another async boundary. A release before this
2667
+ // point owns no upstream lease and must not start one later.
2668
+ if (
2669
+ startupState.cleanedUp ||
2670
+ acquisitions.get(options) !== acquisition
2671
+ ) {
2672
+ return true
2673
+ }
2674
+ if (!resolvedSourceResult.loadSubset) return true
2675
+ acquisition.forwarded = true
2676
+ try {
2677
+ // Returning a promise transfers its lease even if it rejects.
2678
+ // Only a synchronous throw leaves no upstream lease to release.
2679
+ return resolvedSourceResult.loadSubset(loadOptions)
2680
+ } catch (error) {
2681
+ acquisition.forwarded = false
2682
+ throw error
2683
+ }
2684
+ })
2606
2685
  },
2607
2686
  unloadSubset: (options: LoadSubsetOptions) => {
2608
- cancelledLoadKeys.add(getLoadKey(options))
2609
- runtime.unloadSubset(options, sourceResult.unloadSubset)
2687
+ const acquisition = acquisitions.get(options)
2688
+ acquisitions.delete(options)
2689
+ runtime.unloadSubset(
2690
+ options,
2691
+ acquisition?.forwarded ? sourceResult.unloadSubset : undefined,
2692
+ )
2610
2693
  },
2611
2694
  }
2612
2695
  },