@tanstack/db-sqlite-persistence-core 0.4.7 → 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.
Files changed (40) hide show
  1. package/README.md +32 -3
  2. package/dist/cjs/broadcast-coordinator.cjs +282 -43
  3. package/dist/cjs/broadcast-coordinator.cjs.map +1 -1
  4. package/dist/cjs/broadcast-coordinator.d.cts +25 -8
  5. package/dist/cjs/committed-tx-anchor.cjs +9 -0
  6. package/dist/cjs/committed-tx-anchor.cjs.map +1 -0
  7. package/dist/cjs/committed-tx-anchor.d.cts +1 -0
  8. package/dist/cjs/index.cjs +4 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/persisted.cjs +1599 -313
  11. package/dist/cjs/persisted.cjs.map +1 -1
  12. package/dist/cjs/persisted.d.cts +106 -13
  13. package/dist/cjs/remote-subset-wire.cjs +11 -0
  14. package/dist/cjs/remote-subset-wire.cjs.map +1 -1
  15. package/dist/cjs/remote-subset-wire.d.cts +1 -0
  16. package/dist/cjs/sqlite-core-adapter.cjs +705 -64
  17. package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
  18. package/dist/cjs/sqlite-core-adapter.d.cts +53 -10
  19. package/dist/esm/broadcast-coordinator.d.ts +25 -8
  20. package/dist/esm/broadcast-coordinator.js +283 -44
  21. package/dist/esm/broadcast-coordinator.js.map +1 -1
  22. package/dist/esm/committed-tx-anchor.d.ts +1 -0
  23. package/dist/esm/committed-tx-anchor.js +9 -0
  24. package/dist/esm/committed-tx-anchor.js.map +1 -0
  25. package/dist/esm/index.js +6 -2
  26. package/dist/esm/persisted.d.ts +106 -13
  27. package/dist/esm/persisted.js +1601 -315
  28. package/dist/esm/persisted.js.map +1 -1
  29. package/dist/esm/remote-subset-wire.d.ts +1 -0
  30. package/dist/esm/remote-subset-wire.js +11 -0
  31. package/dist/esm/remote-subset-wire.js.map +1 -1
  32. package/dist/esm/sqlite-core-adapter.d.ts +53 -10
  33. package/dist/esm/sqlite-core-adapter.js +708 -67
  34. package/dist/esm/sqlite-core-adapter.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/broadcast-coordinator.ts +415 -62
  37. package/src/committed-tx-anchor.ts +13 -0
  38. package/src/persisted.ts +2283 -370
  39. package/src/remote-subset-wire.ts +12 -0
  40. package/src/sqlite-core-adapter.ts +891 -68
@@ -2,17 +2,20 @@ import {
2
2
  IR,
3
3
  compareTemporalValues,
4
4
  compileSingleRowExpression,
5
+ safeRandomUUID,
5
6
  toBooleanPredicate,
6
7
  } from '@tanstack/db'
7
8
  import {
8
9
  InvalidPersistedCollectionConfigError,
9
10
  InvalidPersistedStorageKeyEncodingError,
10
11
  } from './errors'
12
+ import { isValidCommittedTxAnchor } from './committed-tx-anchor'
11
13
  import {
12
14
  SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY,
13
15
  createPersistedTableName,
14
16
  decodePersistedStorageKey,
15
17
  encodePersistedStorageKey,
18
+ resolvePersistedStorageTarget,
16
19
  } from './persisted'
17
20
  import {
18
21
  PERSISTED_TYPE_TAG,
@@ -26,13 +29,17 @@ import {
26
29
  } from './sqlite-value'
27
30
  import type { LoadSubsetOptions } from '@tanstack/db'
28
31
  import type {
32
+ CommittedTxAnchor,
29
33
  HydrationPersistenceAdapter,
34
+ PersistedCacheGenerationClaim,
30
35
  PersistedIndexSpec,
31
36
  PersistedKeySetEvidence,
32
37
  PersistedRowScanOptions,
33
38
  PersistedScannedRow,
39
+ PersistedStorageTarget,
34
40
  PersistedTx,
35
41
  PersistenceAdapter,
42
+ ReconciledCommittedTx,
36
43
  ReplayableTxDelta,
37
44
  SQLiteDriver,
38
45
  } from './persisted'
@@ -75,6 +82,10 @@ export type SQLiteCoreAdapterOptions = {
75
82
  appliedTxPruneMaxRows?: number
76
83
  appliedTxPruneMaxAgeSeconds?: number
77
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
78
89
  }
79
90
 
80
91
  export type SQLitePullSinceResult<TKey extends string | number> =
@@ -245,6 +256,17 @@ function observeSharedLogicalSchedulingSupport(
245
256
  }
246
257
 
247
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
248
270
  const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128
249
271
 
250
272
  /**
@@ -1619,6 +1641,8 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1619
1641
  private readonly appliedTxPruneMaxAgeSeconds: number | undefined
1620
1642
  private readonly pullSinceReloadThreshold: number
1621
1643
  private readonly replacementBatchSize: number
1644
+ private readonly cacheGenerationClaimTtlMs: number
1645
+ private readonly now: () => number
1622
1646
 
1623
1647
  private initialized = false
1624
1648
  private readonly collectionTableCache = new Map<
@@ -1630,6 +1654,290 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1630
1654
  Promise<CollectionTableMapping>
1631
1655
  >()
1632
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
+
1633
1941
  constructor(options: SQLiteCoreAdapterOptions) {
1634
1942
  const maxBoundParameters = options.driver.maxBoundParameters
1635
1943
  if (
@@ -1645,6 +1953,18 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1645
1953
  ? REPLACEMENT_BATCH_SIZE
1646
1954
  : Math.min(REPLACEMENT_BATCH_SIZE, Math.floor(maxBoundParameters / 4))
1647
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
+
1648
1968
  const schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION
1649
1969
  if (!Number.isInteger(schemaVersion) || schemaVersion < 0) {
1650
1970
  throw new InvalidPersistedCollectionConfigError(
@@ -1713,18 +2033,22 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1713
2033
  this.loadResumeSnapshotUnscheduled(collectionId, context),
1714
2034
  applyCommittedTx: (collectionId, tx) =>
1715
2035
  this.applyCommittedTxUnscheduled(collectionId, tx),
1716
- loadCollectionMetadata: (collectionId) =>
1717
- this.loadCollectionMetadataUnscheduled(collectionId),
1718
- scanRows: (collectionId, scanOptions) =>
1719
- this.scanRowsUnscheduled(collectionId, scanOptions),
1720
- ensureIndex: (collectionId, signature, spec) =>
1721
- this.ensureIndexUnscheduled(collectionId, signature, spec),
1722
- markIndexRemoved: (collectionId, signature) =>
1723
- this.markIndexRemovedUnscheduled(collectionId, signature),
1724
- getStreamPosition: (collectionId) =>
1725
- this.getStreamPositionUnscheduled(collectionId),
1726
- pullSince: (collectionId, fromRowVersion) =>
1727
- this.pullSinceUnscheduled(collectionId, fromRowVersion),
2036
+ renewCacheGenerationClaim: (storageCollectionId, claimId) =>
2037
+ this.renewCacheGenerationClaimUnscheduled(storageCollectionId, claimId),
2038
+ reconcileCommittedTx: (collectionId, tx, anchor) =>
2039
+ this.reconcileCommittedTxUnscheduled(collectionId, tx, anchor),
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),
1728
2052
  runInHydrationScope: async (task) => task(this.hydrationAdapter),
1729
2053
  }
1730
2054
  }
@@ -1804,10 +2128,48 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1804
2128
  }
1805
2129
  }
1806
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
+
1807
2166
  loadSubset(
1808
- collectionId: string,
2167
+ target: PersistedStorageTarget,
1809
2168
  options: LoadSubsetOptions,
1810
- ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
2169
+ ctx?: {
2170
+ requiredIndexSignatures?: ReadonlyArray<string>
2171
+ cacheGenerationClaimId?: string
2172
+ },
1811
2173
  ): Promise<
1812
2174
  Array<{
1813
2175
  key: string | number
@@ -1816,14 +2178,17 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1816
2178
  }>
1817
2179
  > {
1818
2180
  return this.runRegular(() =>
1819
- this.loadSubsetUnscheduled(collectionId, options, ctx),
2181
+ this.loadSubsetUnscheduled(target, options, ctx),
1820
2182
  )
1821
2183
  }
1822
2184
 
1823
2185
  private async loadSubsetUnscheduled(
1824
- collectionId: string,
2186
+ target: PersistedStorageTarget,
1825
2187
  options: LoadSubsetOptions,
1826
- ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
2188
+ ctx?: {
2189
+ requiredIndexSignatures?: ReadonlyArray<string>
2190
+ cacheGenerationClaimId?: string
2191
+ },
1827
2192
  ): Promise<
1828
2193
  Array<{
1829
2194
  key: string | number
@@ -1831,8 +2196,29 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1831
2196
  metadata?: unknown
1832
2197
  }>
1833
2198
  > {
1834
- 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
+ )
1835
2216
  return this.runInTransaction(async (transactionDriver) => {
2217
+ await this.assertCacheGenerationReadClaim(
2218
+ transactionDriver,
2219
+ collectionId,
2220
+ ctx?.cacheGenerationClaimId,
2221
+ )
1836
2222
  await this.assertCurrentSchemaVersion(
1837
2223
  collectionId,
1838
2224
  transactionDriver,
@@ -1906,22 +2292,24 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1906
2292
  }
1907
2293
 
1908
2294
  loadResumeSnapshot(
1909
- collectionId: string,
2295
+ target: PersistedStorageTarget,
1910
2296
  ctx?: {
1911
2297
  requiredIndexSignatures?: ReadonlyArray<string>
1912
2298
  includeRows?: boolean
2299
+ cacheGenerationClaimId?: string
1913
2300
  },
1914
2301
  ) {
1915
2302
  return this.runRegular(() =>
1916
- this.loadResumeSnapshotUnscheduled(collectionId, ctx),
2303
+ this.loadResumeSnapshotUnscheduled(target, ctx),
1917
2304
  )
1918
2305
  }
1919
2306
 
1920
2307
  private async loadResumeSnapshotUnscheduled(
1921
- collectionId: string,
2308
+ target: PersistedStorageTarget,
1922
2309
  ctx?: {
1923
2310
  requiredIndexSignatures?: ReadonlyArray<string>
1924
2311
  includeRows?: boolean
2312
+ cacheGenerationClaimId?: string
1925
2313
  },
1926
2314
  ): Promise<{
1927
2315
  rows: Array<{
@@ -1936,10 +2324,31 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1936
2324
  latestRowVersion: number
1937
2325
  resetEpoch: number
1938
2326
  }> {
1939
- 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
+ )
1940
2344
  const includeRows = ctx?.includeRows !== false
1941
2345
 
1942
2346
  return this.runInTransaction(async (transactionDriver) => {
2347
+ await this.assertCacheGenerationReadClaim(
2348
+ transactionDriver,
2349
+ collectionId,
2350
+ ctx?.cacheGenerationClaimId,
2351
+ )
1943
2352
  await this.assertCurrentSchemaVersion(
1944
2353
  collectionId,
1945
2354
  transactionDriver,
@@ -2000,30 +2409,84 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2000
2409
  })
2001
2410
  }
2002
2411
 
2003
- applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void> {
2412
+ applyCommittedTx(
2413
+ target: PersistedStorageTarget,
2414
+ tx: PersistedTx,
2415
+ ): Promise<void> {
2416
+ return this.runRegular(() => this.applyCommittedTxUnscheduled(target, tx))
2417
+ }
2418
+
2419
+ reconcileCommittedTx(
2420
+ target: PersistedStorageTarget,
2421
+ tx: PersistedTx,
2422
+ anchor: CommittedTxAnchor,
2423
+ ): Promise<ReconciledCommittedTx> {
2004
2424
  return this.runRegular(() =>
2005
- this.applyCommittedTxUnscheduled(collectionId, tx),
2425
+ this.reconcileCommittedTxUnscheduled(target, tx, anchor),
2006
2426
  )
2007
2427
  }
2008
2428
 
2009
2429
  private async applyCommittedTxUnscheduled(
2010
- collectionId: string,
2430
+ target: PersistedStorageTarget,
2011
2431
  tx: PersistedTx,
2012
2432
  ): Promise<void> {
2013
- const tableMapping = await this.ensureCollectionReady(collectionId)
2433
+ await this.applyCommittedTxInternal(target, tx)
2434
+ }
2435
+
2436
+ private reconcileCommittedTxUnscheduled(
2437
+ target: PersistedStorageTarget,
2438
+ tx: PersistedTx,
2439
+ anchor: CommittedTxAnchor,
2440
+ ): Promise<ReconciledCommittedTx> {
2441
+ if (!isValidCommittedTxAnchor(anchor)) {
2442
+ throw new Error(
2443
+ `Cannot reconcile a committed transaction without a valid durable anchor`,
2444
+ )
2445
+ }
2446
+ return this.applyCommittedTxInternal(target, tx, anchor)
2447
+ }
2448
+
2449
+ private async applyCommittedTxInternal(
2450
+ target: PersistedStorageTarget,
2451
+ tx: PersistedTx,
2452
+ anchor?: CommittedTxAnchor,
2453
+ ): Promise<ReconciledCommittedTx> {
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
+ )
2014
2469
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2015
2470
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
2016
2471
 
2017
- await this.runInTransaction(async (transactionDriver) => {
2472
+ return this.runInTransaction(async (transactionDriver) => {
2473
+ await this.assertCacheGenerationReadClaim(
2474
+ transactionDriver,
2475
+ collectionId,
2476
+ tx.cacheGenerationClaimId,
2477
+ )
2018
2478
  const versionRows = await transactionDriver.query<{
2019
2479
  latest_row_version: number
2020
2480
  key_set_evidence_available: number
2021
2481
  schema_version: number
2022
2482
  already_applied: number
2483
+ reset_epoch: number | null
2023
2484
  }>(
2024
2485
  `SELECT
2025
2486
  latest_row_version,
2026
2487
  key_set_evidence_available,
2488
+ (SELECT reset_epoch FROM collection_reset_epoch
2489
+ WHERE collection_id = ? LIMIT 1) AS reset_epoch,
2027
2490
  (
2028
2491
  SELECT schema_version
2029
2492
  FROM collection_registry
@@ -2038,7 +2501,14 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2038
2501
  FROM collection_version
2039
2502
  WHERE collection_id = ?
2040
2503
  LIMIT 1`,
2041
- [collectionId, collectionId, tx.term, tx.seq, collectionId],
2504
+ [
2505
+ collectionId,
2506
+ collectionId,
2507
+ collectionId,
2508
+ tx.term,
2509
+ tx.seq,
2510
+ collectionId,
2511
+ ],
2042
2512
  )
2043
2513
  const version = versionRows[0]
2044
2514
 
@@ -2055,8 +2525,47 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2055
2525
  )
2056
2526
  }
2057
2527
 
2528
+ if (anchor) {
2529
+ const sameResetEpoch = (version.reset_epoch ?? 0) === anchor.resetEpoch
2530
+ const matching = await transactionDriver.query<{
2531
+ term: number
2532
+ seq: number
2533
+ row_version: number
2534
+ }>(
2535
+ `SELECT term, seq, row_version FROM applied_tx
2536
+ WHERE collection_id = ? AND tx_id = ? LIMIT 2`,
2537
+ [collectionId, tx.txId],
2538
+ )
2539
+ if (matching.length > 1) return { kind: `unknown` }
2540
+ if (matching[0]) {
2541
+ if (
2542
+ matching[0].row_version <= anchor.latestRowVersion ||
2543
+ !sameResetEpoch
2544
+ ) {
2545
+ return { kind: `unknown` }
2546
+ }
2547
+ return {
2548
+ kind: `already-applied`,
2549
+ committed: {
2550
+ term: matching[0].term,
2551
+ seq: matching[0].seq,
2552
+ rowVersion: matching[0].row_version,
2553
+ },
2554
+ latestRowVersion: version.latest_row_version,
2555
+ }
2556
+ }
2557
+ // A different durable write may have superseded this source row or
2558
+ // its opaque cursor. Absence alone cannot authorize late application.
2559
+ if (
2560
+ version.latest_row_version !== anchor.latestRowVersion ||
2561
+ !sameResetEpoch
2562
+ ) {
2563
+ return { kind: `unknown` }
2564
+ }
2565
+ }
2566
+
2058
2567
  if (version.already_applied === 1) {
2059
- return
2568
+ return { kind: `unknown` }
2060
2569
  }
2061
2570
 
2062
2571
  const currentRowVersion = version.latest_row_version
@@ -2366,22 +2875,51 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2366
2875
  )
2367
2876
 
2368
2877
  await this.pruneAppliedTxRows(collectionId, transactionDriver)
2878
+ return {
2879
+ kind: `applied-now`,
2880
+ committed: {
2881
+ term: tx.term,
2882
+ seq: tx.seq,
2883
+ rowVersion: nextRowVersion,
2884
+ },
2885
+ latestRowVersion: nextRowVersion,
2886
+ }
2369
2887
  })
2370
2888
  }
2371
2889
 
2372
2890
  loadCollectionMetadata(
2373
- collectionId: string,
2891
+ target: PersistedStorageTarget,
2892
+ ctx?: { cacheGenerationClaimId?: string },
2374
2893
  ): Promise<Array<{ key: string; value: unknown }>> {
2375
2894
  return this.runRegular(() =>
2376
- this.loadCollectionMetadataUnscheduled(collectionId),
2895
+ this.loadCollectionMetadataUnscheduled(target, ctx),
2377
2896
  )
2378
2897
  }
2379
2898
 
2380
2899
  private async loadCollectionMetadataUnscheduled(
2381
- collectionId: string,
2900
+ target: PersistedStorageTarget,
2901
+ ctx?: { cacheGenerationClaimId?: string },
2382
2902
  ): Promise<Array<{ key: string; value: unknown }>> {
2383
- 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)
2384
2917
  return this.runInTransaction(async (transactionDriver) => {
2918
+ await this.assertCacheGenerationReadClaim(
2919
+ transactionDriver,
2920
+ collectionId,
2921
+ ctx?.cacheGenerationClaimId,
2922
+ )
2385
2923
  await this.assertCurrentSchemaVersion(
2386
2924
  collectionId,
2387
2925
  transactionDriver,
@@ -2405,21 +2943,42 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2405
2943
  }
2406
2944
 
2407
2945
  scanRows(
2408
- collectionId: string,
2946
+ target: PersistedStorageTarget,
2409
2947
  options?: PersistedRowScanOptions,
2948
+ ctx?: { cacheGenerationClaimId?: string },
2410
2949
  ): Promise<Array<PersistedScannedRow>> {
2411
- return this.runRegular(() =>
2412
- this.scanRowsUnscheduled(collectionId, options),
2413
- )
2950
+ return this.runRegular(() => this.scanRowsUnscheduled(target, options, ctx))
2414
2951
  }
2415
2952
 
2416
2953
  private async scanRowsUnscheduled(
2417
- collectionId: string,
2954
+ target: PersistedStorageTarget,
2418
2955
  options?: PersistedRowScanOptions,
2956
+ ctx?: { cacheGenerationClaimId?: string },
2419
2957
  ): Promise<Array<PersistedScannedRow>> {
2420
- 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
+ )
2421
2975
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2422
2976
  return this.runInTransaction(async (transactionDriver) => {
2977
+ await this.assertCacheGenerationReadClaim(
2978
+ transactionDriver,
2979
+ collectionId,
2980
+ ctx?.cacheGenerationClaimId,
2981
+ )
2423
2982
  await this.assertCurrentSchemaVersion(
2424
2983
  collectionId,
2425
2984
  transactionDriver,
@@ -2443,21 +3002,39 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2443
3002
  }
2444
3003
 
2445
3004
  ensureIndex(
2446
- collectionId: string,
3005
+ target: PersistedStorageTarget,
2447
3006
  signature: string,
2448
3007
  spec: PersistedIndexSpec,
3008
+ ctx?: { cacheGenerationClaimId?: string },
2449
3009
  ): Promise<void> {
2450
3010
  return this.runRegular(() =>
2451
- this.ensureIndexUnscheduled(collectionId, signature, spec),
3011
+ this.ensureIndexUnscheduled(target, signature, spec, ctx),
2452
3012
  )
2453
3013
  }
2454
3014
 
2455
3015
  private async ensureIndexUnscheduled(
2456
- collectionId: string,
3016
+ target: PersistedStorageTarget,
2457
3017
  signature: string,
2458
3018
  spec: PersistedIndexSpec,
3019
+ ctx?: { cacheGenerationClaimId?: string },
2459
3020
  ): Promise<void> {
2460
- 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
+ )
2461
3038
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2462
3039
  const indexName = buildIndexName(collectionId, signature)
2463
3040
  const indexNameSql = quoteIdentifier(indexName)
@@ -2472,6 +3049,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2472
3049
  const persistedWhereSql = whereSql ?? null
2473
3050
 
2474
3051
  await this.runInTransaction(async (transactionDriver) => {
3052
+ await this.assertCacheGenerationReadClaim(
3053
+ transactionDriver,
3054
+ collectionId,
3055
+ ctx?.cacheGenerationClaimId,
3056
+ )
2475
3057
  await this.assertCurrentSchemaVersion(
2476
3058
  collectionId,
2477
3059
  transactionDriver,
@@ -2545,18 +3127,41 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2545
3127
  })
2546
3128
  }
2547
3129
 
2548
- markIndexRemoved(collectionId: string, signature: string): Promise<void> {
3130
+ markIndexRemoved(
3131
+ target: PersistedStorageTarget,
3132
+ signature: string,
3133
+ ctx?: { cacheGenerationClaimId?: string },
3134
+ ): Promise<void> {
2549
3135
  return this.runRegular(() =>
2550
- this.markIndexRemovedUnscheduled(collectionId, signature),
3136
+ this.markIndexRemovedUnscheduled(target, signature, ctx),
2551
3137
  )
2552
3138
  }
2553
3139
 
2554
3140
  private async markIndexRemovedUnscheduled(
2555
- collectionId: string,
3141
+ target: PersistedStorageTarget,
2556
3142
  signature: string,
3143
+ ctx?: { cacheGenerationClaimId?: string },
2557
3144
  ): Promise<void> {
2558
- 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)
2559
3159
  await this.runInTransaction(async (transactionDriver) => {
3160
+ await this.assertCacheGenerationReadClaim(
3161
+ transactionDriver,
3162
+ collectionId,
3163
+ ctx?.cacheGenerationClaimId,
3164
+ )
2560
3165
  await this.assertCurrentSchemaVersion(
2561
3166
  collectionId,
2562
3167
  transactionDriver,
@@ -2588,23 +3193,109 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2588
3193
  })
2589
3194
  }
2590
3195
 
2591
- getStreamPosition(collectionId: string): Promise<{
3196
+ reserveLeadershipTerm(
3197
+ target: PersistedStorageTarget,
3198
+ observedTerm: number,
3199
+ ctx?: { cacheGenerationClaimId?: string },
3200
+ ): Promise<{
3201
+ latestTerm: number
3202
+ latestSeq: number
3203
+ latestRowVersion: number
3204
+ }> {
3205
+ // Election may run while hydration waits for its first writer route.
3206
+ return this.reserveLeadershipTermUnscheduled(target, observedTerm, ctx)
3207
+ }
3208
+
3209
+ private async reserveLeadershipTermUnscheduled(
3210
+ target: PersistedStorageTarget,
3211
+ observedTerm: number,
3212
+ ctx?: { cacheGenerationClaimId?: string },
3213
+ ): Promise<{
3214
+ latestTerm: number
3215
+ latestSeq: number
3216
+ latestRowVersion: number
3217
+ }> {
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)
3232
+ return this.runInTransaction(async (transactionDriver) => {
3233
+ await this.assertCacheGenerationReadClaim(
3234
+ transactionDriver,
3235
+ collectionId,
3236
+ ctx?.cacheGenerationClaimId,
3237
+ )
3238
+ await this.assertCurrentSchemaVersion(
3239
+ collectionId,
3240
+ transactionDriver,
3241
+ `reserve a leadership term`,
3242
+ )
3243
+ await transactionDriver.run(
3244
+ `INSERT INTO leader_term (collection_id, latest_term)
3245
+ VALUES (?, ? + 1)
3246
+ ON CONFLICT(collection_id) DO UPDATE SET
3247
+ latest_term = MAX(leader_term.latest_term, ?) + 1`,
3248
+ [collectionId, observedTerm, observedTerm],
3249
+ )
3250
+ const [position, latestRowVersion] = await Promise.all([
3251
+ this.readStreamPosition(collectionId, transactionDriver),
3252
+ this.readLatestRowVersion(collectionId, transactionDriver),
3253
+ ])
3254
+ return { ...position, latestRowVersion }
3255
+ })
3256
+ }
3257
+
3258
+ getStreamPosition(
3259
+ target: PersistedStorageTarget,
3260
+ ctx?: { cacheGenerationClaimId?: string },
3261
+ ): Promise<{
2592
3262
  latestTerm: number
2593
3263
  latestSeq: number
2594
3264
  latestRowVersion: number
2595
3265
  }> {
2596
3266
  // Election must not queue behind a hydrate awaiting its first writer route.
2597
3267
  // The stream-position snapshot still uses the driver's transaction admission.
2598
- return this.getStreamPositionUnscheduled(collectionId)
3268
+ return this.getStreamPositionUnscheduled(target, ctx)
2599
3269
  }
2600
3270
 
2601
- private async getStreamPositionUnscheduled(collectionId: string): Promise<{
3271
+ private async getStreamPositionUnscheduled(
3272
+ target: PersistedStorageTarget,
3273
+ ctx?: { cacheGenerationClaimId?: string },
3274
+ ): Promise<{
2602
3275
  latestTerm: number
2603
3276
  latestSeq: number
2604
3277
  latestRowVersion: number
2605
3278
  }> {
2606
- 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)
2607
3293
  return this.runInTransaction(async (transactionDriver) => {
3294
+ await this.assertCacheGenerationReadClaim(
3295
+ transactionDriver,
3296
+ collectionId,
3297
+ ctx?.cacheGenerationClaimId,
3298
+ )
2608
3299
  await this.assertCurrentSchemaVersion(
2609
3300
  collectionId,
2610
3301
  transactionDriver,
@@ -2701,23 +3392,46 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2701
3392
  }
2702
3393
 
2703
3394
  pullSince(
2704
- collectionId: string,
3395
+ target: PersistedStorageTarget,
2705
3396
  fromRowVersion: number,
3397
+ ctx?: { cacheGenerationClaimId?: string },
2706
3398
  ): Promise<SQLitePullSinceResult<string | number>> {
2707
3399
  return this.runRegular(() =>
2708
- this.pullSinceUnscheduled(collectionId, fromRowVersion),
3400
+ this.pullSinceUnscheduled(target, fromRowVersion, ctx),
2709
3401
  )
2710
3402
  }
2711
3403
 
2712
3404
  private async pullSinceUnscheduled(
2713
- collectionId: string,
3405
+ target: PersistedStorageTarget,
2714
3406
  fromRowVersion: number,
3407
+ ctx?: { cacheGenerationClaimId?: string },
2715
3408
  ): Promise<SQLitePullSinceResult<string | number>> {
2716
- 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
+ )
2717
3426
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2718
3427
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
2719
3428
 
2720
3429
  return this.runInTransaction(async (transactionDriver) => {
3430
+ await this.assertCacheGenerationReadClaim(
3431
+ transactionDriver,
3432
+ collectionId,
3433
+ ctx?.cacheGenerationClaimId,
3434
+ )
2721
3435
  await this.assertCurrentSchemaVersion(
2722
3436
  collectionId,
2723
3437
  transactionDriver,
@@ -3042,6 +3756,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3042
3756
 
3043
3757
  private async ensureCollectionReady(
3044
3758
  collectionId: string,
3759
+ cacheGenerationClaimId?: string,
3045
3760
  ): Promise<CollectionTableMapping> {
3046
3761
  await this.ensureInitialized()
3047
3762
 
@@ -3055,7 +3770,10 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3055
3770
  return inFlight
3056
3771
  }
3057
3772
 
3058
- const loadPromise = this.ensureCollectionReadyInternal(collectionId)
3773
+ const loadPromise = this.ensureCollectionReadyInternal(
3774
+ collectionId,
3775
+ cacheGenerationClaimId,
3776
+ )
3059
3777
  this.collectionTableLoads.set(collectionId, loadPromise)
3060
3778
 
3061
3779
  try {
@@ -3090,24 +3808,43 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3090
3808
 
3091
3809
  private async ensureCollectionReadyInternal(
3092
3810
  collectionId: string,
3811
+ cacheGenerationClaimId?: string,
3093
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)
3094
3824
  let registration = await this.loadCollectionRegistration(collectionId)
3095
3825
 
3096
3826
  if (!registration) {
3097
3827
  const tableName = createPersistedTableName(collectionId, `c`)
3098
3828
  const tombstoneTableName = createPersistedTableName(collectionId, `t`)
3099
- await this.driver.run(
3100
- `INSERT INTO collection_registry (
3101
- collection_id,
3102
- table_name,
3103
- tombstone_table_name,
3104
- schema_version,
3105
- updated_at
3106
- )
3107
- VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
3108
- ON CONFLICT DO NOTHING`,
3109
- [collectionId, tableName, tombstoneTableName, this.schemaVersion],
3110
- )
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
+ })
3111
3848
 
3112
3849
  registration = await this.loadCollectionRegistration(collectionId)
3113
3850
  if (!registration) {
@@ -3126,6 +3863,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3126
3863
  this.schemaVersion,
3127
3864
  tableName,
3128
3865
  tombstoneTableName,
3866
+ cacheGenerationClaimId,
3129
3867
  )
3130
3868
  }
3131
3869
 
@@ -3174,6 +3912,38 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3174
3912
  [collectionId],
3175
3913
  )
3176
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
+ }
3177
3947
  const mapping = {
3178
3948
  tableName,
3179
3949
  tombstoneTableName,
@@ -3255,6 +4025,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3255
4025
  nextSchemaVersion: number,
3256
4026
  tableName: string,
3257
4027
  tombstoneTableName: string,
4028
+ cacheGenerationClaimId?: string,
3258
4029
  ): Promise<void> {
3259
4030
  if (this.schemaMismatchPolicy === `sync-absent-error`) {
3260
4031
  throw new InvalidPersistedCollectionConfigError(
@@ -3267,6 +4038,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3267
4038
  const tombstoneTableSql = quoteIdentifier(tombstoneTableName)
3268
4039
 
3269
4040
  await this.runInTransaction(async (transactionDriver) => {
4041
+ await this.assertCacheGenerationReadClaim(
4042
+ transactionDriver,
4043
+ collectionId,
4044
+ cacheGenerationClaimId,
4045
+ )
3270
4046
  const currentSchemaRows = await transactionDriver.query<{
3271
4047
  schema_version: number
3272
4048
  }>(
@@ -3361,6 +4137,49 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3361
4137
  return
3362
4138
  }
3363
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
+
3364
4183
  await this.driver.exec(
3365
4184
  `CREATE TABLE IF NOT EXISTS collection_registry (
3366
4185
  collection_id TEXT PRIMARY KEY,
@@ -3397,6 +4216,10 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
3397
4216
  PRIMARY KEY (collection_id, term, seq)
3398
4217
  )`,
3399
4218
  )
4219
+ await this.driver.exec(
4220
+ `CREATE INDEX IF NOT EXISTS idx_applied_tx_collection_tx_id
4221
+ ON applied_tx (collection_id, tx_id)`,
4222
+ )
3400
4223
  try {
3401
4224
  await this.driver.exec(
3402
4225
  `ALTER TABLE applied_tx ADD COLUMN replay_json TEXT`,