@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.
@@ -2,6 +2,7 @@ import {
2
2
  IR,
3
3
  compareTemporalValues,
4
4
  compileSingleRowExpression,
5
+ safeRandomUUID,
5
6
  toBooleanPredicate,
6
7
  } from '@tanstack/db'
7
8
  import {
@@ -14,6 +15,7 @@ import {
14
15
  createPersistedTableName,
15
16
  decodePersistedStorageKey,
16
17
  encodePersistedStorageKey,
18
+ resolvePersistedStorageTarget,
17
19
  } from './persisted'
18
20
  import {
19
21
  PERSISTED_TYPE_TAG,
@@ -29,10 +31,12 @@ import type { LoadSubsetOptions } from '@tanstack/db'
29
31
  import type {
30
32
  CommittedTxAnchor,
31
33
  HydrationPersistenceAdapter,
34
+ PersistedCacheGenerationClaim,
32
35
  PersistedIndexSpec,
33
36
  PersistedKeySetEvidence,
34
37
  PersistedRowScanOptions,
35
38
  PersistedScannedRow,
39
+ PersistedStorageTarget,
36
40
  PersistedTx,
37
41
  PersistenceAdapter,
38
42
  ReconciledCommittedTx,
@@ -78,6 +82,10 @@ export type SQLiteCoreAdapterOptions = {
78
82
  appliedTxPruneMaxRows?: number
79
83
  appliedTxPruneMaxAgeSeconds?: number
80
84
  pullSinceReloadThreshold?: number
85
+ /** Milliseconds without renewal before a sync run loses cache access. */
86
+ cacheGenerationClaimTtlMs?: number
87
+ /** Host clock, injectable for expiry histories. */
88
+ now?: () => number
81
89
  }
82
90
 
83
91
  export type SQLitePullSinceResult<TKey extends string | number> =
@@ -248,6 +256,17 @@ function observeSharedLogicalSchedulingSupport(
248
256
  }
249
257
 
250
258
  const DEFAULT_SCHEMA_VERSION = 1
259
+ const CACHE_GENERATION_ID_PREFIX = `tanstack-db-cache:`
260
+ const LEGACY_CACHE_GENERATION_ID_PREFIX = `\u0000tanstack-db-cache:`
261
+
262
+ function hasCacheGenerationIdPrefix(collectionId: string): boolean {
263
+ return (
264
+ collectionId.startsWith(CACHE_GENERATION_ID_PREFIX) ||
265
+ collectionId.startsWith(LEGACY_CACHE_GENERATION_ID_PREFIX)
266
+ )
267
+ }
268
+
269
+ export const DEFAULT_CACHE_GENERATION_CLAIM_TTL_MS = 5 * 60_000
251
270
  const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128
252
271
 
253
272
  /**
@@ -1622,6 +1641,8 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1622
1641
  private readonly appliedTxPruneMaxAgeSeconds: number | undefined
1623
1642
  private readonly pullSinceReloadThreshold: number
1624
1643
  private readonly replacementBatchSize: number
1644
+ private readonly cacheGenerationClaimTtlMs: number
1645
+ private readonly now: () => number
1625
1646
 
1626
1647
  private initialized = false
1627
1648
  private readonly collectionTableCache = new Map<
@@ -1633,6 +1654,290 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1633
1654
  Promise<CollectionTableMapping>
1634
1655
  >()
1635
1656
 
1657
+ claimCacheGeneration(
1658
+ collectionId: string,
1659
+ ): Promise<PersistedCacheGenerationClaim> {
1660
+ return this.runRegular(async () => {
1661
+ await this.ensureInitialized()
1662
+ return this.driver.transaction(async (driver) => {
1663
+ const expiresAtMs = this.now() + this.cacheGenerationClaimTtlMs
1664
+ // The first generation must not reuse the legacy collection ID: an older
1665
+ // adapter can still write that ID without a claim after an upgrade.
1666
+ const initialPhysicalId = `${CACHE_GENERATION_ID_PREFIX}${safeRandomUUID()}`
1667
+ await driver.run(
1668
+ `INSERT INTO cache_generation (logical_id, generation, physical_id, retired)
1669
+ SELECT ?, 0, ?, 0
1670
+ WHERE NOT EXISTS (
1671
+ SELECT 1 FROM cache_generation WHERE logical_id = ?
1672
+ )`,
1673
+ [collectionId, initialPhysicalId, collectionId],
1674
+ )
1675
+ const head = await driver.query<{ physical_id: string }>(
1676
+ `SELECT physical_id FROM cache_generation
1677
+ WHERE logical_id = ? AND retired = 0`,
1678
+ [collectionId],
1679
+ )
1680
+ const storageCollectionId = head[0]?.physical_id
1681
+ if (!storageCollectionId) {
1682
+ throw new InvalidPersistedCollectionConfigError(
1683
+ `No active persisted cache generation for collection "${collectionId}"`,
1684
+ )
1685
+ }
1686
+ const claimId = safeRandomUUID()
1687
+ await driver.run(
1688
+ `INSERT INTO cache_generation_claim
1689
+ (claim_id, logical_id, physical_id, expires_at_ms)
1690
+ VALUES (?, ?, ?, ?)`,
1691
+ [claimId, collectionId, storageCollectionId, expiresAtMs],
1692
+ )
1693
+ await this.collectRetiredCacheGenerations(driver)
1694
+ return { storageCollectionId, claimId, expiresAtMs }
1695
+ })
1696
+ })
1697
+ }
1698
+
1699
+ rotateCacheGeneration(
1700
+ collectionId: string,
1701
+ claimId: string,
1702
+ resetMetadata?: { key: string; value: unknown },
1703
+ expectedStorageCollectionId?: string,
1704
+ ): Promise<PersistedCacheGenerationClaim> {
1705
+ return this.runRegular(async () => {
1706
+ await this.ensureInitialized()
1707
+ return this.driver.transaction(async (driver) => {
1708
+ const now = this.now()
1709
+ const expiresAtMs = now + this.cacheGenerationClaimTtlMs
1710
+ const claimed = await driver.query<{
1711
+ physical_id: string
1712
+ expires_at_ms: number
1713
+ }>(
1714
+ `SELECT physical_id, expires_at_ms
1715
+ FROM cache_generation_claim
1716
+ WHERE claim_id = ? AND logical_id = ?`,
1717
+ [claimId, collectionId],
1718
+ )
1719
+ const claimedPhysicalId =
1720
+ claimed[0] && claimed[0].expires_at_ms > now
1721
+ ? claimed[0].physical_id
1722
+ : undefined
1723
+ if (
1724
+ claimedPhysicalId &&
1725
+ expectedStorageCollectionId &&
1726
+ claimedPhysicalId !== expectedStorageCollectionId
1727
+ ) {
1728
+ throw new InvalidPersistedCollectionConfigError(
1729
+ `Persisted cache claim changed generations before recovery`,
1730
+ )
1731
+ }
1732
+ const head = await driver.query<{
1733
+ generation: number
1734
+ physical_id: string
1735
+ }>(
1736
+ `SELECT generation, physical_id FROM cache_generation
1737
+ WHERE logical_id = ? AND retired = 0`,
1738
+ [collectionId],
1739
+ )
1740
+ const active = head[0]
1741
+ if (!active) {
1742
+ throw new InvalidPersistedCollectionConfigError(
1743
+ `No active persisted cache generation for collection "${collectionId}"`,
1744
+ )
1745
+ }
1746
+ await driver.run(
1747
+ `INSERT INTO cache_generation_sequence (logical_id, next_generation)
1748
+ SELECT ?, COALESCE(MAX(generation), -1) + 1
1749
+ FROM cache_generation WHERE logical_id = ?
1750
+ ON CONFLICT(logical_id) DO NOTHING`,
1751
+ [collectionId, collectionId],
1752
+ )
1753
+ const nextRows = await driver.query<{ next_generation: number }>(
1754
+ `SELECT next_generation FROM cache_generation_sequence
1755
+ WHERE logical_id = ?`,
1756
+ [collectionId],
1757
+ )
1758
+ const nextGeneration = nextRows[0]!.next_generation
1759
+ await driver.run(
1760
+ `UPDATE cache_generation_sequence SET next_generation = ?
1761
+ WHERE logical_id = ?`,
1762
+ [nextGeneration + 1, collectionId],
1763
+ )
1764
+ // An expired claim no longer owns the head, even when it remembers
1765
+ // the current storage ID. Its recovery is private so another run's
1766
+ // valid current cache remains claimable.
1767
+ const ownedHead = claimedPhysicalId === active.physical_id
1768
+ const storageCollectionId = `${CACHE_GENERATION_ID_PREFIX}${safeRandomUUID()}`
1769
+ if (ownedHead) {
1770
+ await driver.run(
1771
+ `UPDATE cache_generation SET retired = 1
1772
+ WHERE logical_id = ? AND generation = ?`,
1773
+ [collectionId, active.generation],
1774
+ )
1775
+ }
1776
+ await driver.run(
1777
+ `INSERT INTO cache_generation (logical_id, generation, physical_id, retired)
1778
+ VALUES (?, ?, ?, ?)`,
1779
+ [
1780
+ collectionId,
1781
+ nextGeneration,
1782
+ storageCollectionId,
1783
+ ownedHead ? 0 : 1,
1784
+ ],
1785
+ )
1786
+ const nextClaimId = claimed[0] ? claimId : safeRandomUUID()
1787
+ if (claimed[0]) {
1788
+ await driver.run(
1789
+ `UPDATE cache_generation_claim
1790
+ SET physical_id = ?, expires_at_ms = ? WHERE claim_id = ?`,
1791
+ [storageCollectionId, expiresAtMs, claimId],
1792
+ )
1793
+ } else {
1794
+ await driver.run(
1795
+ `INSERT INTO cache_generation_claim
1796
+ (claim_id, logical_id, physical_id, expires_at_ms)
1797
+ VALUES (?, ?, ?, ?)`,
1798
+ [nextClaimId, collectionId, storageCollectionId, expiresAtMs],
1799
+ )
1800
+ }
1801
+ await driver.run(
1802
+ `INSERT INTO collection_version (
1803
+ collection_id, latest_row_version,
1804
+ key_set_evidence_available, key_set_evidence_incompatible
1805
+ ) VALUES (?, 0, 1, 1)`,
1806
+ [storageCollectionId],
1807
+ )
1808
+ if (resetMetadata) {
1809
+ await driver.run(
1810
+ `INSERT INTO collection_metadata (collection_id, key, value, updated_at)
1811
+ VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`,
1812
+ [
1813
+ storageCollectionId,
1814
+ resetMetadata.key,
1815
+ serializePersistedRowValue(resetMetadata.value),
1816
+ ],
1817
+ )
1818
+ }
1819
+ await this.collectRetiredCacheGenerations(driver)
1820
+ return { storageCollectionId, claimId: nextClaimId, expiresAtMs }
1821
+ })
1822
+ })
1823
+ }
1824
+
1825
+ releaseCacheGenerationClaim(claimId: string): Promise<void> {
1826
+ return this.runRegular(async () => {
1827
+ await this.ensureInitialized()
1828
+ await this.runInTransaction(async (driver) => {
1829
+ await driver.run(
1830
+ `DELETE FROM cache_generation_claim WHERE claim_id = ?`,
1831
+ [claimId],
1832
+ )
1833
+ await this.collectRetiredCacheGenerations(driver)
1834
+ })
1835
+ })
1836
+ }
1837
+
1838
+ renewCacheGenerationClaim(
1839
+ storageCollectionId: string,
1840
+ claimId: string,
1841
+ ): Promise<number | undefined> {
1842
+ return this.runRegular(() =>
1843
+ this.renewCacheGenerationClaimUnscheduled(storageCollectionId, claimId),
1844
+ )
1845
+ }
1846
+
1847
+ private async renewCacheGenerationClaimUnscheduled(
1848
+ storageCollectionId: string,
1849
+ claimId: string,
1850
+ ): Promise<number | undefined> {
1851
+ await this.ensureInitialized()
1852
+ return this.runInTransaction(async (driver) => {
1853
+ const now = this.now()
1854
+ const rows = await driver.query<{ claim_id: string }>(
1855
+ `SELECT claim_id FROM cache_generation_claim
1856
+ WHERE claim_id = ? AND physical_id = ? AND expires_at_ms > ?`,
1857
+ [claimId, storageCollectionId, now],
1858
+ )
1859
+ if (!rows[0]) {
1860
+ await this.collectRetiredCacheGenerations(driver)
1861
+ return undefined
1862
+ }
1863
+ const expiresAtMs = now + this.cacheGenerationClaimTtlMs
1864
+ await driver.run(
1865
+ `UPDATE cache_generation_claim SET expires_at_ms = ?
1866
+ WHERE claim_id = ?`,
1867
+ [expiresAtMs, claimId],
1868
+ )
1869
+ return expiresAtMs
1870
+ })
1871
+ }
1872
+
1873
+ private async collectRetiredCacheGenerations(
1874
+ driver: SQLiteDriver,
1875
+ ): Promise<void> {
1876
+ await driver.run(
1877
+ `DELETE FROM cache_generation_claim WHERE expires_at_ms <= ?`,
1878
+ [this.now()],
1879
+ )
1880
+ const retired = await driver.query<{
1881
+ physical_id: string
1882
+ table_name: string | null
1883
+ tombstone_table_name: string | null
1884
+ }>(
1885
+ `SELECT generation.physical_id, registry.table_name,
1886
+ registry.tombstone_table_name
1887
+ FROM cache_generation AS generation
1888
+ LEFT JOIN collection_registry AS registry
1889
+ ON registry.collection_id = generation.physical_id
1890
+ WHERE generation.retired = 1
1891
+ AND NOT EXISTS (
1892
+ SELECT 1 FROM cache_generation_claim AS claim
1893
+ WHERE claim.physical_id = generation.physical_id
1894
+ )`,
1895
+ )
1896
+ for (const generation of retired) {
1897
+ if (generation.table_name) {
1898
+ await driver.exec(
1899
+ `DROP TABLE IF EXISTS ${quoteIdentifier(generation.table_name)}`,
1900
+ )
1901
+ }
1902
+ if (generation.tombstone_table_name) {
1903
+ await driver.exec(
1904
+ `DROP TABLE IF EXISTS ${quoteIdentifier(generation.tombstone_table_name)}`,
1905
+ )
1906
+ }
1907
+ for (const table of [
1908
+ `applied_tx`,
1909
+ `collection_expected_keys`,
1910
+ `collection_metadata`,
1911
+ `persisted_index_registry`,
1912
+ `leader_term`,
1913
+ ]) {
1914
+ await driver.run(`DELETE FROM ${table} WHERE collection_id = ?`, [
1915
+ generation.physical_id,
1916
+ ])
1917
+ }
1918
+ await driver.run(
1919
+ `DELETE FROM collection_version WHERE collection_id = ?`,
1920
+ [generation.physical_id],
1921
+ )
1922
+ await driver.run(
1923
+ `DELETE FROM collection_reset_epoch WHERE collection_id = ?`,
1924
+ [generation.physical_id],
1925
+ )
1926
+ await driver.run(
1927
+ `DELETE FROM collection_registry WHERE collection_id = ?`,
1928
+ [generation.physical_id],
1929
+ )
1930
+ await driver.run(`DELETE FROM cache_generation WHERE physical_id = ?`, [
1931
+ generation.physical_id,
1932
+ ])
1933
+ this.collectionTableCache.delete(generation.physical_id)
1934
+ }
1935
+ }
1936
+
1937
+ getCacheGenerationNow(): number {
1938
+ return this.now()
1939
+ }
1940
+
1636
1941
  constructor(options: SQLiteCoreAdapterOptions) {
1637
1942
  const maxBoundParameters = options.driver.maxBoundParameters
1638
1943
  if (
@@ -1648,6 +1953,18 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1648
1953
  ? REPLACEMENT_BATCH_SIZE
1649
1954
  : Math.min(REPLACEMENT_BATCH_SIZE, Math.floor(maxBoundParameters / 4))
1650
1955
 
1956
+ this.cacheGenerationClaimTtlMs =
1957
+ options.cacheGenerationClaimTtlMs ?? DEFAULT_CACHE_GENERATION_CLAIM_TTL_MS
1958
+ if (
1959
+ !Number.isSafeInteger(this.cacheGenerationClaimTtlMs) ||
1960
+ this.cacheGenerationClaimTtlMs <= 0
1961
+ ) {
1962
+ throw new InvalidPersistedCollectionConfigError(
1963
+ `SQLite adapter cacheGenerationClaimTtlMs must be a positive safe integer`,
1964
+ )
1965
+ }
1966
+ this.now = options.now ?? Date.now
1967
+
1651
1968
  const schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION
1652
1969
  if (!Number.isInteger(schemaVersion) || schemaVersion < 0) {
1653
1970
  throw new InvalidPersistedCollectionConfigError(
@@ -1716,20 +2033,22 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1716
2033
  this.loadResumeSnapshotUnscheduled(collectionId, context),
1717
2034
  applyCommittedTx: (collectionId, tx) =>
1718
2035
  this.applyCommittedTxUnscheduled(collectionId, tx),
2036
+ renewCacheGenerationClaim: (storageCollectionId, claimId) =>
2037
+ this.renewCacheGenerationClaimUnscheduled(storageCollectionId, claimId),
1719
2038
  reconcileCommittedTx: (collectionId, tx, anchor) =>
1720
2039
  this.reconcileCommittedTxUnscheduled(collectionId, tx, anchor),
1721
- loadCollectionMetadata: (collectionId) =>
1722
- this.loadCollectionMetadataUnscheduled(collectionId),
1723
- scanRows: (collectionId, scanOptions) =>
1724
- this.scanRowsUnscheduled(collectionId, scanOptions),
1725
- ensureIndex: (collectionId, signature, spec) =>
1726
- this.ensureIndexUnscheduled(collectionId, signature, spec),
1727
- markIndexRemoved: (collectionId, signature) =>
1728
- this.markIndexRemovedUnscheduled(collectionId, signature),
1729
- getStreamPosition: (collectionId) =>
1730
- this.getStreamPositionUnscheduled(collectionId),
1731
- pullSince: (collectionId, fromRowVersion) =>
1732
- this.pullSinceUnscheduled(collectionId, fromRowVersion),
2040
+ loadCollectionMetadata: (collectionId, context) =>
2041
+ this.loadCollectionMetadataUnscheduled(collectionId, context),
2042
+ scanRows: (collectionId, scanOptions, context) =>
2043
+ this.scanRowsUnscheduled(collectionId, scanOptions, context),
2044
+ ensureIndex: (collectionId, signature, spec, ctx) =>
2045
+ this.ensureIndexUnscheduled(collectionId, signature, spec, ctx),
2046
+ markIndexRemoved: (collectionId, signature, ctx) =>
2047
+ this.markIndexRemovedUnscheduled(collectionId, signature, ctx),
2048
+ getStreamPosition: (collectionId, ctx) =>
2049
+ this.getStreamPositionUnscheduled(collectionId, ctx),
2050
+ pullSince: (collectionId, fromRowVersion, ctx) =>
2051
+ this.pullSinceUnscheduled(collectionId, fromRowVersion, ctx),
1733
2052
  runInHydrationScope: async (task) => task(this.hydrationAdapter),
1734
2053
  }
1735
2054
  }
@@ -1809,10 +2128,48 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1809
2128
  }
1810
2129
  }
1811
2130
 
2131
+ private async assertCacheGenerationReadClaim(
2132
+ driver: SQLiteDriver,
2133
+ storageCollectionId: string,
2134
+ claimId?: string,
2135
+ ): Promise<void> {
2136
+ await this.ensureInitialized()
2137
+ if (!claimId) {
2138
+ if (!hasCacheGenerationIdPrefix(storageCollectionId)) return
2139
+ const generation = await driver.query<{ physical_id: string }>(
2140
+ `SELECT physical_id FROM cache_generation WHERE physical_id = ?`,
2141
+ [storageCollectionId],
2142
+ )
2143
+ if (generation[0]) {
2144
+ throw new InvalidPersistedCollectionConfigError(
2145
+ `Persisted cache claim is required for collection "${storageCollectionId}"`,
2146
+ )
2147
+ }
2148
+ return
2149
+ }
2150
+ const rows = await driver.query<{ claim_id: string }>(
2151
+ `SELECT claim.claim_id
2152
+ FROM cache_generation_claim AS claim
2153
+ JOIN cache_generation AS generation
2154
+ ON generation.physical_id = claim.physical_id
2155
+ WHERE claim.claim_id = ? AND claim.physical_id = ?
2156
+ AND claim.expires_at_ms > ?`,
2157
+ [claimId, storageCollectionId, this.now()],
2158
+ )
2159
+ if (!rows[0]) {
2160
+ throw new InvalidPersistedCollectionConfigError(
2161
+ `Persisted cache claim is no longer active for collection "${storageCollectionId}"`,
2162
+ )
2163
+ }
2164
+ }
2165
+
1812
2166
  loadSubset(
1813
- collectionId: string,
2167
+ target: PersistedStorageTarget,
1814
2168
  options: LoadSubsetOptions,
1815
- ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
2169
+ ctx?: {
2170
+ requiredIndexSignatures?: ReadonlyArray<string>
2171
+ cacheGenerationClaimId?: string
2172
+ },
1816
2173
  ): Promise<
1817
2174
  Array<{
1818
2175
  key: string | number
@@ -1821,14 +2178,17 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1821
2178
  }>
1822
2179
  > {
1823
2180
  return this.runRegular(() =>
1824
- this.loadSubsetUnscheduled(collectionId, options, ctx),
2181
+ this.loadSubsetUnscheduled(target, options, ctx),
1825
2182
  )
1826
2183
  }
1827
2184
 
1828
2185
  private async loadSubsetUnscheduled(
1829
- collectionId: string,
2186
+ target: PersistedStorageTarget,
1830
2187
  options: LoadSubsetOptions,
1831
- ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
2188
+ ctx?: {
2189
+ requiredIndexSignatures?: ReadonlyArray<string>
2190
+ cacheGenerationClaimId?: string
2191
+ },
1832
2192
  ): Promise<
1833
2193
  Array<{
1834
2194
  key: string | number
@@ -1836,8 +2196,29 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1836
2196
  metadata?: unknown
1837
2197
  }>
1838
2198
  > {
1839
- const tableMapping = await this.ensureCollectionReady(collectionId)
2199
+ const access = resolvePersistedStorageTarget(
2200
+ target,
2201
+ ctx?.cacheGenerationClaimId,
2202
+ )
2203
+ const collectionId = access.collectionId
2204
+ ctx = access.claimId
2205
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
2206
+ : ctx
2207
+ await this.assertCacheGenerationReadClaim(
2208
+ this.driver,
2209
+ collectionId,
2210
+ ctx?.cacheGenerationClaimId,
2211
+ )
2212
+ const tableMapping = await this.ensureCollectionReady(
2213
+ collectionId,
2214
+ ctx?.cacheGenerationClaimId,
2215
+ )
1840
2216
  return this.runInTransaction(async (transactionDriver) => {
2217
+ await this.assertCacheGenerationReadClaim(
2218
+ transactionDriver,
2219
+ collectionId,
2220
+ ctx?.cacheGenerationClaimId,
2221
+ )
1841
2222
  await this.assertCurrentSchemaVersion(
1842
2223
  collectionId,
1843
2224
  transactionDriver,
@@ -1911,22 +2292,24 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1911
2292
  }
1912
2293
 
1913
2294
  loadResumeSnapshot(
1914
- collectionId: string,
2295
+ target: PersistedStorageTarget,
1915
2296
  ctx?: {
1916
2297
  requiredIndexSignatures?: ReadonlyArray<string>
1917
2298
  includeRows?: boolean
2299
+ cacheGenerationClaimId?: string
1918
2300
  },
1919
2301
  ) {
1920
2302
  return this.runRegular(() =>
1921
- this.loadResumeSnapshotUnscheduled(collectionId, ctx),
2303
+ this.loadResumeSnapshotUnscheduled(target, ctx),
1922
2304
  )
1923
2305
  }
1924
2306
 
1925
2307
  private async loadResumeSnapshotUnscheduled(
1926
- collectionId: string,
2308
+ target: PersistedStorageTarget,
1927
2309
  ctx?: {
1928
2310
  requiredIndexSignatures?: ReadonlyArray<string>
1929
2311
  includeRows?: boolean
2312
+ cacheGenerationClaimId?: string
1930
2313
  },
1931
2314
  ): Promise<{
1932
2315
  rows: Array<{
@@ -1941,10 +2324,31 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1941
2324
  latestRowVersion: number
1942
2325
  resetEpoch: number
1943
2326
  }> {
1944
- const tableMapping = await this.ensureCollectionReady(collectionId)
2327
+ const access = resolvePersistedStorageTarget(
2328
+ target,
2329
+ ctx?.cacheGenerationClaimId,
2330
+ )
2331
+ const collectionId = access.collectionId
2332
+ ctx = access.claimId
2333
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
2334
+ : ctx
2335
+ await this.assertCacheGenerationReadClaim(
2336
+ this.driver,
2337
+ collectionId,
2338
+ ctx?.cacheGenerationClaimId,
2339
+ )
2340
+ const tableMapping = await this.ensureCollectionReady(
2341
+ collectionId,
2342
+ ctx?.cacheGenerationClaimId,
2343
+ )
1945
2344
  const includeRows = ctx?.includeRows !== false
1946
2345
 
1947
2346
  return this.runInTransaction(async (transactionDriver) => {
2347
+ await this.assertCacheGenerationReadClaim(
2348
+ transactionDriver,
2349
+ collectionId,
2350
+ ctx?.cacheGenerationClaimId,
2351
+ )
1948
2352
  await this.assertCurrentSchemaVersion(
1949
2353
  collectionId,
1950
2354
  transactionDriver,
@@ -2005,31 +2409,32 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2005
2409
  })
2006
2410
  }
2007
2411
 
2008
- applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void> {
2009
- return this.runRegular(() =>
2010
- this.applyCommittedTxUnscheduled(collectionId, tx),
2011
- )
2412
+ applyCommittedTx(
2413
+ target: PersistedStorageTarget,
2414
+ tx: PersistedTx,
2415
+ ): Promise<void> {
2416
+ return this.runRegular(() => this.applyCommittedTxUnscheduled(target, tx))
2012
2417
  }
2013
2418
 
2014
2419
  reconcileCommittedTx(
2015
- collectionId: string,
2420
+ target: PersistedStorageTarget,
2016
2421
  tx: PersistedTx,
2017
2422
  anchor: CommittedTxAnchor,
2018
2423
  ): Promise<ReconciledCommittedTx> {
2019
2424
  return this.runRegular(() =>
2020
- this.reconcileCommittedTxUnscheduled(collectionId, tx, anchor),
2425
+ this.reconcileCommittedTxUnscheduled(target, tx, anchor),
2021
2426
  )
2022
2427
  }
2023
2428
 
2024
2429
  private async applyCommittedTxUnscheduled(
2025
- collectionId: string,
2430
+ target: PersistedStorageTarget,
2026
2431
  tx: PersistedTx,
2027
2432
  ): Promise<void> {
2028
- await this.applyCommittedTxInternal(collectionId, tx)
2433
+ await this.applyCommittedTxInternal(target, tx)
2029
2434
  }
2030
2435
 
2031
2436
  private reconcileCommittedTxUnscheduled(
2032
- collectionId: string,
2437
+ target: PersistedStorageTarget,
2033
2438
  tx: PersistedTx,
2034
2439
  anchor: CommittedTxAnchor,
2035
2440
  ): Promise<ReconciledCommittedTx> {
@@ -2038,19 +2443,38 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2038
2443
  `Cannot reconcile a committed transaction without a valid durable anchor`,
2039
2444
  )
2040
2445
  }
2041
- return this.applyCommittedTxInternal(collectionId, tx, anchor)
2446
+ return this.applyCommittedTxInternal(target, tx, anchor)
2042
2447
  }
2043
2448
 
2044
2449
  private async applyCommittedTxInternal(
2045
- collectionId: string,
2450
+ target: PersistedStorageTarget,
2046
2451
  tx: PersistedTx,
2047
2452
  anchor?: CommittedTxAnchor,
2048
2453
  ): Promise<ReconciledCommittedTx> {
2049
- const tableMapping = await this.ensureCollectionReady(collectionId)
2454
+ const access = resolvePersistedStorageTarget(
2455
+ target,
2456
+ tx.cacheGenerationClaimId,
2457
+ )
2458
+ const collectionId = access.collectionId
2459
+ tx = { ...tx, cacheGenerationClaimId: access.claimId }
2460
+ await this.assertCacheGenerationReadClaim(
2461
+ this.driver,
2462
+ collectionId,
2463
+ tx.cacheGenerationClaimId,
2464
+ )
2465
+ const tableMapping = await this.ensureCollectionReady(
2466
+ collectionId,
2467
+ tx.cacheGenerationClaimId,
2468
+ )
2050
2469
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2051
2470
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
2052
2471
 
2053
2472
  return this.runInTransaction(async (transactionDriver) => {
2473
+ await this.assertCacheGenerationReadClaim(
2474
+ transactionDriver,
2475
+ collectionId,
2476
+ tx.cacheGenerationClaimId,
2477
+ )
2054
2478
  const versionRows = await transactionDriver.query<{
2055
2479
  latest_row_version: number
2056
2480
  key_set_evidence_available: number
@@ -2464,18 +2888,38 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2464
2888
  }
2465
2889
 
2466
2890
  loadCollectionMetadata(
2467
- collectionId: string,
2891
+ target: PersistedStorageTarget,
2892
+ ctx?: { cacheGenerationClaimId?: string },
2468
2893
  ): Promise<Array<{ key: string; value: unknown }>> {
2469
2894
  return this.runRegular(() =>
2470
- this.loadCollectionMetadataUnscheduled(collectionId),
2895
+ this.loadCollectionMetadataUnscheduled(target, ctx),
2471
2896
  )
2472
2897
  }
2473
2898
 
2474
2899
  private async loadCollectionMetadataUnscheduled(
2475
- collectionId: string,
2900
+ target: PersistedStorageTarget,
2901
+ ctx?: { cacheGenerationClaimId?: string },
2476
2902
  ): Promise<Array<{ key: string; value: unknown }>> {
2477
- await this.ensureCollectionReady(collectionId)
2903
+ const access = resolvePersistedStorageTarget(
2904
+ target,
2905
+ ctx?.cacheGenerationClaimId,
2906
+ )
2907
+ const collectionId = access.collectionId
2908
+ ctx = access.claimId
2909
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
2910
+ : ctx
2911
+ await this.assertCacheGenerationReadClaim(
2912
+ this.driver,
2913
+ collectionId,
2914
+ ctx?.cacheGenerationClaimId,
2915
+ )
2916
+ await this.ensureCollectionReady(collectionId, ctx?.cacheGenerationClaimId)
2478
2917
  return this.runInTransaction(async (transactionDriver) => {
2918
+ await this.assertCacheGenerationReadClaim(
2919
+ transactionDriver,
2920
+ collectionId,
2921
+ ctx?.cacheGenerationClaimId,
2922
+ )
2479
2923
  await this.assertCurrentSchemaVersion(
2480
2924
  collectionId,
2481
2925
  transactionDriver,
@@ -2499,21 +2943,42 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2499
2943
  }
2500
2944
 
2501
2945
  scanRows(
2502
- collectionId: string,
2946
+ target: PersistedStorageTarget,
2503
2947
  options?: PersistedRowScanOptions,
2948
+ ctx?: { cacheGenerationClaimId?: string },
2504
2949
  ): Promise<Array<PersistedScannedRow>> {
2505
- return this.runRegular(() =>
2506
- this.scanRowsUnscheduled(collectionId, options),
2507
- )
2950
+ return this.runRegular(() => this.scanRowsUnscheduled(target, options, ctx))
2508
2951
  }
2509
2952
 
2510
2953
  private async scanRowsUnscheduled(
2511
- collectionId: string,
2954
+ target: PersistedStorageTarget,
2512
2955
  options?: PersistedRowScanOptions,
2956
+ ctx?: { cacheGenerationClaimId?: string },
2513
2957
  ): Promise<Array<PersistedScannedRow>> {
2514
- const tableMapping = await this.ensureCollectionReady(collectionId)
2958
+ const access = resolvePersistedStorageTarget(
2959
+ target,
2960
+ ctx?.cacheGenerationClaimId,
2961
+ )
2962
+ const collectionId = access.collectionId
2963
+ ctx = access.claimId
2964
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
2965
+ : ctx
2966
+ await this.assertCacheGenerationReadClaim(
2967
+ this.driver,
2968
+ collectionId,
2969
+ ctx?.cacheGenerationClaimId,
2970
+ )
2971
+ const tableMapping = await this.ensureCollectionReady(
2972
+ collectionId,
2973
+ ctx?.cacheGenerationClaimId,
2974
+ )
2515
2975
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2516
2976
  return this.runInTransaction(async (transactionDriver) => {
2977
+ await this.assertCacheGenerationReadClaim(
2978
+ transactionDriver,
2979
+ collectionId,
2980
+ ctx?.cacheGenerationClaimId,
2981
+ )
2517
2982
  await this.assertCurrentSchemaVersion(
2518
2983
  collectionId,
2519
2984
  transactionDriver,
@@ -2537,21 +3002,39 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2537
3002
  }
2538
3003
 
2539
3004
  ensureIndex(
2540
- collectionId: string,
3005
+ target: PersistedStorageTarget,
2541
3006
  signature: string,
2542
3007
  spec: PersistedIndexSpec,
3008
+ ctx?: { cacheGenerationClaimId?: string },
2543
3009
  ): Promise<void> {
2544
3010
  return this.runRegular(() =>
2545
- this.ensureIndexUnscheduled(collectionId, signature, spec),
3011
+ this.ensureIndexUnscheduled(target, signature, spec, ctx),
2546
3012
  )
2547
3013
  }
2548
3014
 
2549
3015
  private async ensureIndexUnscheduled(
2550
- collectionId: string,
3016
+ target: PersistedStorageTarget,
2551
3017
  signature: string,
2552
3018
  spec: PersistedIndexSpec,
3019
+ ctx?: { cacheGenerationClaimId?: string },
2553
3020
  ): Promise<void> {
2554
- const tableMapping = await this.ensureCollectionReady(collectionId)
3021
+ const access = resolvePersistedStorageTarget(
3022
+ target,
3023
+ ctx?.cacheGenerationClaimId,
3024
+ )
3025
+ const collectionId = access.collectionId
3026
+ ctx = access.claimId
3027
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
3028
+ : ctx
3029
+ await this.assertCacheGenerationReadClaim(
3030
+ this.driver,
3031
+ collectionId,
3032
+ ctx?.cacheGenerationClaimId,
3033
+ )
3034
+ const tableMapping = await this.ensureCollectionReady(
3035
+ collectionId,
3036
+ ctx?.cacheGenerationClaimId,
3037
+ )
2555
3038
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2556
3039
  const indexName = buildIndexName(collectionId, signature)
2557
3040
  const indexNameSql = quoteIdentifier(indexName)
@@ -2566,6 +3049,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2566
3049
  const persistedWhereSql = whereSql ?? null
2567
3050
 
2568
3051
  await this.runInTransaction(async (transactionDriver) => {
3052
+ await this.assertCacheGenerationReadClaim(
3053
+ transactionDriver,
3054
+ collectionId,
3055
+ ctx?.cacheGenerationClaimId,
3056
+ )
2569
3057
  await this.assertCurrentSchemaVersion(
2570
3058
  collectionId,
2571
3059
  transactionDriver,
@@ -2639,18 +3127,41 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2639
3127
  })
2640
3128
  }
2641
3129
 
2642
- markIndexRemoved(collectionId: string, signature: string): Promise<void> {
3130
+ markIndexRemoved(
3131
+ target: PersistedStorageTarget,
3132
+ signature: string,
3133
+ ctx?: { cacheGenerationClaimId?: string },
3134
+ ): Promise<void> {
2643
3135
  return this.runRegular(() =>
2644
- this.markIndexRemovedUnscheduled(collectionId, signature),
3136
+ this.markIndexRemovedUnscheduled(target, signature, ctx),
2645
3137
  )
2646
3138
  }
2647
3139
 
2648
3140
  private async markIndexRemovedUnscheduled(
2649
- collectionId: string,
3141
+ target: PersistedStorageTarget,
2650
3142
  signature: string,
3143
+ ctx?: { cacheGenerationClaimId?: string },
2651
3144
  ): Promise<void> {
2652
- await this.ensureCollectionReady(collectionId)
3145
+ const access = resolvePersistedStorageTarget(
3146
+ target,
3147
+ ctx?.cacheGenerationClaimId,
3148
+ )
3149
+ const collectionId = access.collectionId
3150
+ ctx = access.claimId
3151
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
3152
+ : ctx
3153
+ await this.assertCacheGenerationReadClaim(
3154
+ this.driver,
3155
+ collectionId,
3156
+ ctx?.cacheGenerationClaimId,
3157
+ )
3158
+ await this.ensureCollectionReady(collectionId, ctx?.cacheGenerationClaimId)
2653
3159
  await this.runInTransaction(async (transactionDriver) => {
3160
+ await this.assertCacheGenerationReadClaim(
3161
+ transactionDriver,
3162
+ collectionId,
3163
+ ctx?.cacheGenerationClaimId,
3164
+ )
2654
3165
  await this.assertCurrentSchemaVersion(
2655
3166
  collectionId,
2656
3167
  transactionDriver,
@@ -2683,27 +3194,47 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2683
3194
  }
2684
3195
 
2685
3196
  reserveLeadershipTerm(
2686
- collectionId: string,
3197
+ target: PersistedStorageTarget,
2687
3198
  observedTerm: number,
3199
+ ctx?: { cacheGenerationClaimId?: string },
2688
3200
  ): Promise<{
2689
3201
  latestTerm: number
2690
3202
  latestSeq: number
2691
3203
  latestRowVersion: number
2692
3204
  }> {
2693
3205
  // Election may run while hydration waits for its first writer route.
2694
- return this.reserveLeadershipTermUnscheduled(collectionId, observedTerm)
3206
+ return this.reserveLeadershipTermUnscheduled(target, observedTerm, ctx)
2695
3207
  }
2696
3208
 
2697
3209
  private async reserveLeadershipTermUnscheduled(
2698
- collectionId: string,
3210
+ target: PersistedStorageTarget,
2699
3211
  observedTerm: number,
3212
+ ctx?: { cacheGenerationClaimId?: string },
2700
3213
  ): Promise<{
2701
3214
  latestTerm: number
2702
3215
  latestSeq: number
2703
3216
  latestRowVersion: number
2704
3217
  }> {
2705
- await this.ensureCollectionReady(collectionId)
3218
+ const access = resolvePersistedStorageTarget(
3219
+ target,
3220
+ ctx?.cacheGenerationClaimId,
3221
+ )
3222
+ const collectionId = access.collectionId
3223
+ ctx = access.claimId
3224
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
3225
+ : ctx
3226
+ await this.assertCacheGenerationReadClaim(
3227
+ this.driver,
3228
+ collectionId,
3229
+ ctx?.cacheGenerationClaimId,
3230
+ )
3231
+ await this.ensureCollectionReady(collectionId, ctx?.cacheGenerationClaimId)
2706
3232
  return this.runInTransaction(async (transactionDriver) => {
3233
+ await this.assertCacheGenerationReadClaim(
3234
+ transactionDriver,
3235
+ collectionId,
3236
+ ctx?.cacheGenerationClaimId,
3237
+ )
2707
3238
  await this.assertCurrentSchemaVersion(
2708
3239
  collectionId,
2709
3240
  transactionDriver,
@@ -2724,23 +3255,47 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2724
3255
  })
2725
3256
  }
2726
3257
 
2727
- getStreamPosition(collectionId: string): Promise<{
3258
+ getStreamPosition(
3259
+ target: PersistedStorageTarget,
3260
+ ctx?: { cacheGenerationClaimId?: string },
3261
+ ): Promise<{
2728
3262
  latestTerm: number
2729
3263
  latestSeq: number
2730
3264
  latestRowVersion: number
2731
3265
  }> {
2732
3266
  // Election must not queue behind a hydrate awaiting its first writer route.
2733
3267
  // The stream-position snapshot still uses the driver's transaction admission.
2734
- return this.getStreamPositionUnscheduled(collectionId)
3268
+ return this.getStreamPositionUnscheduled(target, ctx)
2735
3269
  }
2736
3270
 
2737
- private async getStreamPositionUnscheduled(collectionId: string): Promise<{
3271
+ private async getStreamPositionUnscheduled(
3272
+ target: PersistedStorageTarget,
3273
+ ctx?: { cacheGenerationClaimId?: string },
3274
+ ): Promise<{
2738
3275
  latestTerm: number
2739
3276
  latestSeq: number
2740
3277
  latestRowVersion: number
2741
3278
  }> {
2742
- await this.ensureCollectionReady(collectionId)
3279
+ const access = resolvePersistedStorageTarget(
3280
+ target,
3281
+ ctx?.cacheGenerationClaimId,
3282
+ )
3283
+ const collectionId = access.collectionId
3284
+ ctx = access.claimId
3285
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
3286
+ : ctx
3287
+ await this.assertCacheGenerationReadClaim(
3288
+ this.driver,
3289
+ collectionId,
3290
+ ctx?.cacheGenerationClaimId,
3291
+ )
3292
+ await this.ensureCollectionReady(collectionId, ctx?.cacheGenerationClaimId)
2743
3293
  return this.runInTransaction(async (transactionDriver) => {
3294
+ await this.assertCacheGenerationReadClaim(
3295
+ transactionDriver,
3296
+ collectionId,
3297
+ ctx?.cacheGenerationClaimId,
3298
+ )
2744
3299
  await this.assertCurrentSchemaVersion(
2745
3300
  collectionId,
2746
3301
  transactionDriver,
@@ -2837,23 +3392,46 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2837
3392
  }
2838
3393
 
2839
3394
  pullSince(
2840
- collectionId: string,
3395
+ target: PersistedStorageTarget,
2841
3396
  fromRowVersion: number,
3397
+ ctx?: { cacheGenerationClaimId?: string },
2842
3398
  ): Promise<SQLitePullSinceResult<string | number>> {
2843
3399
  return this.runRegular(() =>
2844
- this.pullSinceUnscheduled(collectionId, fromRowVersion),
3400
+ this.pullSinceUnscheduled(target, fromRowVersion, ctx),
2845
3401
  )
2846
3402
  }
2847
3403
 
2848
3404
  private async pullSinceUnscheduled(
2849
- collectionId: string,
3405
+ target: PersistedStorageTarget,
2850
3406
  fromRowVersion: number,
3407
+ ctx?: { cacheGenerationClaimId?: string },
2851
3408
  ): Promise<SQLitePullSinceResult<string | number>> {
2852
- const tableMapping = await this.ensureCollectionReady(collectionId)
3409
+ const access = resolvePersistedStorageTarget(
3410
+ target,
3411
+ ctx?.cacheGenerationClaimId,
3412
+ )
3413
+ const collectionId = access.collectionId
3414
+ ctx = access.claimId
3415
+ ? { ...ctx, cacheGenerationClaimId: access.claimId }
3416
+ : ctx
3417
+ await this.assertCacheGenerationReadClaim(
3418
+ this.driver,
3419
+ collectionId,
3420
+ ctx?.cacheGenerationClaimId,
3421
+ )
3422
+ const tableMapping = await this.ensureCollectionReady(
3423
+ collectionId,
3424
+ ctx?.cacheGenerationClaimId,
3425
+ )
2853
3426
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2854
3427
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
2855
3428
 
2856
3429
  return this.runInTransaction(async (transactionDriver) => {
3430
+ await this.assertCacheGenerationReadClaim(
3431
+ transactionDriver,
3432
+ collectionId,
3433
+ ctx?.cacheGenerationClaimId,
3434
+ )
2857
3435
  await this.assertCurrentSchemaVersion(
2858
3436
  collectionId,
2859
3437
  transactionDriver,
@@ -3178,6 +3756,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3178
3756
 
3179
3757
  private async ensureCollectionReady(
3180
3758
  collectionId: string,
3759
+ cacheGenerationClaimId?: string,
3181
3760
  ): Promise<CollectionTableMapping> {
3182
3761
  await this.ensureInitialized()
3183
3762
 
@@ -3191,7 +3770,10 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3191
3770
  return inFlight
3192
3771
  }
3193
3772
 
3194
- const loadPromise = this.ensureCollectionReadyInternal(collectionId)
3773
+ const loadPromise = this.ensureCollectionReadyInternal(
3774
+ collectionId,
3775
+ cacheGenerationClaimId,
3776
+ )
3195
3777
  this.collectionTableLoads.set(collectionId, loadPromise)
3196
3778
 
3197
3779
  try {
@@ -3226,24 +3808,43 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3226
3808
 
3227
3809
  private async ensureCollectionReadyInternal(
3228
3810
  collectionId: string,
3811
+ cacheGenerationClaimId?: string,
3229
3812
  ): Promise<CollectionTableMapping> {
3813
+ // Record whether registration began for a managed physical ID. Collection
3814
+ // may remove that catalog row while this operation waits on external DDL.
3815
+ const managedAtAdmission =
3816
+ cacheGenerationClaimId !== undefined ||
3817
+ (hasCacheGenerationIdPrefix(collectionId) &&
3818
+ (
3819
+ await this.driver.query<{ physical_id: string }>(
3820
+ `SELECT physical_id FROM cache_generation WHERE physical_id = ?`,
3821
+ [collectionId],
3822
+ )
3823
+ )[0] !== undefined)
3230
3824
  let registration = await this.loadCollectionRegistration(collectionId)
3231
3825
 
3232
3826
  if (!registration) {
3233
3827
  const tableName = createPersistedTableName(collectionId, `c`)
3234
3828
  const tombstoneTableName = createPersistedTableName(collectionId, `t`)
3235
- await this.driver.run(
3236
- `INSERT INTO collection_registry (
3237
- collection_id,
3238
- table_name,
3239
- tombstone_table_name,
3240
- schema_version,
3241
- updated_at
3242
- )
3243
- VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
3244
- ON CONFLICT DO NOTHING`,
3245
- [collectionId, tableName, tombstoneTableName, this.schemaVersion],
3246
- )
3829
+ await this.runInTransaction(async (transactionDriver) => {
3830
+ await this.assertCacheGenerationReadClaim(
3831
+ transactionDriver,
3832
+ collectionId,
3833
+ cacheGenerationClaimId,
3834
+ )
3835
+ await transactionDriver.run(
3836
+ `INSERT INTO collection_registry (
3837
+ collection_id,
3838
+ table_name,
3839
+ tombstone_table_name,
3840
+ schema_version,
3841
+ updated_at
3842
+ )
3843
+ VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
3844
+ ON CONFLICT DO NOTHING`,
3845
+ [collectionId, tableName, tombstoneTableName, this.schemaVersion],
3846
+ )
3847
+ })
3247
3848
 
3248
3849
  registration = await this.loadCollectionRegistration(collectionId)
3249
3850
  if (!registration) {
@@ -3262,6 +3863,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3262
3863
  this.schemaVersion,
3263
3864
  tableName,
3264
3865
  tombstoneTableName,
3866
+ cacheGenerationClaimId,
3265
3867
  )
3266
3868
  }
3267
3869
 
@@ -3310,6 +3912,38 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3310
3912
  [collectionId],
3311
3913
  )
3312
3914
  await this.ensureCollectionKeyEvidenceTriggers(collectionId, tableName)
3915
+ if (managedAtAdmission) {
3916
+ // Registration is outside the read/write transaction. If collection
3917
+ // retired this generation while the DDL was in flight, remove any
3918
+ // empty table recreated after collection instead of caching it.
3919
+ const generation = await this.driver.query<{ physical_id: string }>(
3920
+ `SELECT physical_id FROM cache_generation WHERE physical_id = ?`,
3921
+ [collectionId],
3922
+ )
3923
+ if (!generation[0]) {
3924
+ await this.driver.exec(
3925
+ `DROP TABLE IF EXISTS ${quoteIdentifier(tableName)}`,
3926
+ )
3927
+ await this.driver.exec(
3928
+ `DROP TABLE IF EXISTS ${quoteIdentifier(tombstoneTableName)}`,
3929
+ )
3930
+ await this.driver.run(
3931
+ `DELETE FROM collection_registry WHERE collection_id = ?`,
3932
+ [collectionId],
3933
+ )
3934
+ await this.driver.run(
3935
+ `DELETE FROM collection_version WHERE collection_id = ?`,
3936
+ [collectionId],
3937
+ )
3938
+ await this.driver.run(
3939
+ `DELETE FROM collection_reset_epoch WHERE collection_id = ?`,
3940
+ [collectionId],
3941
+ )
3942
+ throw new InvalidPersistedCollectionConfigError(
3943
+ `Persisted cache generation was collected before registration finished`,
3944
+ )
3945
+ }
3946
+ }
3313
3947
  const mapping = {
3314
3948
  tableName,
3315
3949
  tombstoneTableName,
@@ -3391,6 +4025,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3391
4025
  nextSchemaVersion: number,
3392
4026
  tableName: string,
3393
4027
  tombstoneTableName: string,
4028
+ cacheGenerationClaimId?: string,
3394
4029
  ): Promise<void> {
3395
4030
  if (this.schemaMismatchPolicy === `sync-absent-error`) {
3396
4031
  throw new InvalidPersistedCollectionConfigError(
@@ -3403,6 +4038,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3403
4038
  const tombstoneTableSql = quoteIdentifier(tombstoneTableName)
3404
4039
 
3405
4040
  await this.runInTransaction(async (transactionDriver) => {
4041
+ await this.assertCacheGenerationReadClaim(
4042
+ transactionDriver,
4043
+ collectionId,
4044
+ cacheGenerationClaimId,
4045
+ )
3406
4046
  const currentSchemaRows = await transactionDriver.query<{
3407
4047
  schema_version: number
3408
4048
  }>(
@@ -3497,6 +4137,49 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3497
4137
  return
3498
4138
  }
3499
4139
 
4140
+ await this.driver.exec(
4141
+ `CREATE TABLE IF NOT EXISTS cache_generation (
4142
+ logical_id TEXT NOT NULL,
4143
+ generation INTEGER NOT NULL,
4144
+ physical_id TEXT NOT NULL UNIQUE,
4145
+ retired INTEGER NOT NULL DEFAULT 0,
4146
+ PRIMARY KEY (logical_id, generation)
4147
+ )`,
4148
+ )
4149
+ await this.driver.exec(
4150
+ `CREATE UNIQUE INDEX IF NOT EXISTS cache_generation_head
4151
+ ON cache_generation (logical_id) WHERE retired = 0`,
4152
+ )
4153
+ await this.driver.exec(
4154
+ `CREATE TABLE IF NOT EXISTS cache_generation_sequence (
4155
+ logical_id TEXT PRIMARY KEY,
4156
+ next_generation INTEGER NOT NULL
4157
+ )`,
4158
+ )
4159
+ await this.driver.exec(
4160
+ `CREATE TABLE IF NOT EXISTS cache_generation_claim (
4161
+ claim_id TEXT PRIMARY KEY,
4162
+ logical_id TEXT NOT NULL,
4163
+ physical_id TEXT NOT NULL,
4164
+ expires_at_ms INTEGER NOT NULL
4165
+ )`,
4166
+ )
4167
+ const claimColumns = await this.driver.query<{ name: string }>(
4168
+ `PRAGMA table_info(cache_generation_claim)`,
4169
+ )
4170
+ if (!claimColumns.some(({ name }) => name === `logical_id`)) {
4171
+ await this.driver.exec(
4172
+ `ALTER TABLE cache_generation_claim
4173
+ ADD COLUMN logical_id TEXT NOT NULL DEFAULT ''`,
4174
+ )
4175
+ }
4176
+ if (!claimColumns.some(({ name }) => name === `expires_at_ms`)) {
4177
+ await this.driver.exec(
4178
+ `ALTER TABLE cache_generation_claim
4179
+ ADD COLUMN expires_at_ms INTEGER NOT NULL DEFAULT 0`,
4180
+ )
4181
+ }
4182
+
3500
4183
  await this.driver.exec(
3501
4184
  `CREATE TABLE IF NOT EXISTS collection_registry (
3502
4185
  collection_id TEXT PRIMARY KEY,