diff --git a/.changeset/node-default-applied-tx-pruning.md b/.changeset/node-default-applied-tx-pruning.md new file mode 100644 index 000000000..28958ca90 --- /dev/null +++ b/.changeset/node-default-applied-tx-pruning.md @@ -0,0 +1,14 @@ +--- +'@tanstack/browser-db-sqlite-persistence': minor +'@tanstack/capacitor-db-sqlite-persistence': minor +'@tanstack/cloudflare-durable-objects-db-sqlite-persistence': minor +'@tanstack/db-sqlite-persistence-core': minor +'@tanstack/expo-db-sqlite-persistence': minor +'@tanstack/node-db-sqlite-persistence': minor +'@tanstack/react-native-db-sqlite-persistence': minor +'@tanstack/tauri-db-sqlite-persistence': minor +--- + +SQLite persistence wrappers now prune the `applied_tx` replay log by default so SQLite files no longer grow without bound. When prune options are omitted, wrappers that construct the shared SQLite core adapter apply `appliedTxPruneMaxRows: 1_000` and `appliedTxPruneMaxAgeSeconds: 86_400` (24h). Both remain overridable, and passing `0` disables that limit. The defaults are exported as `DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS` and `DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS` from the shared SQLite core package and re-exported by wrapper packages. + +The shared SQLite core adapter now treats `applied_tx` as a bounded replay cache during `pullSince` recovery. If a recovery request starts before the retained replay window, `pullSince` returns `requiresFullReload: true` instead of returning partial deltas. diff --git a/packages/browser-db-sqlite-persistence/src/browser-persistence.ts b/packages/browser-db-sqlite-persistence/src/browser-persistence.ts index bfa049bf6..b36e56490 100644 --- a/packages/browser-db-sqlite-persistence/src/browser-persistence.ts +++ b/packages/browser-db-sqlite-persistence/src/browser-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -78,8 +80,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/browser-db-sqlite-persistence/src/index.ts b/packages/browser-db-sqlite-persistence/src/index.ts index 5931b8ade..992e8c092 100644 --- a/packages/browser-db-sqlite-persistence/src/index.ts +++ b/packages/browser-db-sqlite-persistence/src/index.ts @@ -8,7 +8,11 @@ export type { } from './browser-persistence' export type { OpenBrowserWASQLiteOPFSDatabaseOptions } from './opfs-database' export type { BrowserCollectionCoordinatorOptions } from './browser-coordinator' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/capacitor-db-sqlite-persistence/src/capacitor-persistence.ts b/packages/capacitor-db-sqlite-persistence/src/capacitor-persistence.ts index 9eea35b9b..e293ea71b 100644 --- a/packages/capacitor-db-sqlite-persistence/src/capacitor-persistence.ts +++ b/packages/capacitor-db-sqlite-persistence/src/capacitor-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -81,8 +83,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/capacitor-db-sqlite-persistence/src/index.ts b/packages/capacitor-db-sqlite-persistence/src/index.ts index 81f8b6c40..1aad1c786 100644 --- a/packages/capacitor-db-sqlite-persistence/src/index.ts +++ b/packages/capacitor-db-sqlite-persistence/src/index.ts @@ -5,7 +5,11 @@ export type { CapacitorSQLiteSchemaMismatchPolicy, SQLiteDBConnection, } from './capacitor' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/src/do-persistence.ts b/packages/cloudflare-durable-objects-db-sqlite-persistence/src/do-persistence.ts index bf6c7667f..3d5ef2311 100644 --- a/packages/cloudflare-durable-objects-db-sqlite-persistence/src/do-persistence.ts +++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/src/do-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -79,8 +81,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/src/index.ts b/packages/cloudflare-durable-objects-db-sqlite-persistence/src/index.ts index 5740c803b..714e8912f 100644 --- a/packages/cloudflare-durable-objects-db-sqlite-persistence/src/index.ts +++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/src/index.ts @@ -4,7 +4,11 @@ export type { CloudflareDOSQLitePersistenceOptions, DurableObjectStorageLike, } from './do-persistence' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts b/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts index 6bbf06c4d..69f29fc60 100644 --- a/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts +++ b/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts @@ -73,6 +73,21 @@ export type SQLitePullSinceResult = const DEFAULT_SCHEMA_VERSION = 1 const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128 + +/** + * Default cap on retained `applied_tx` rows per collection. The log is a + * replayable cache, so a bounded row count keeps SQLite files from growing + * without limit. Pass `appliedTxPruneMaxRows: 0` to disable the row cap. + */ +export const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = 1_000 + +/** + * Default age backstop for retained `applied_tx` rows, in seconds (24h). Rows + * older than this are pruned on the next write. Pass + * `appliedTxPruneMaxAgeSeconds: 0` to disable the age backstop. + */ +export const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60 + const SQLITE_MAX_IN_BATCH_SIZE = 900 const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/ const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/ @@ -1540,42 +1555,64 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter { const collectionTableSql = quoteIdentifier(tableMapping.tableName) const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName) - const [changedRows, deletedRows, latestVersionRows, replayRows] = - await Promise.all([ - this.driver.query<{ key: string }>( - `SELECT key + const [ + changedRows, + deletedRows, + latestVersionRows, + replayRows, + replayAvailabilityRows, + ] = await Promise.all([ + this.driver.query<{ key: string }>( + `SELECT key FROM ${collectionTableSql} WHERE row_version > ?`, - [fromRowVersion], - ), - this.driver.query<{ key: string }>( - `SELECT key + [fromRowVersion], + ), + this.driver.query<{ key: string }>( + `SELECT key FROM ${tombstoneTableSql} WHERE row_version > ?`, - [fromRowVersion], - ), - this.driver.query<{ latest_row_version: number }>( - `SELECT latest_row_version + [fromRowVersion], + ), + this.driver.query<{ latest_row_version: number }>( + `SELECT latest_row_version FROM collection_version WHERE collection_id = ? LIMIT 1`, - [collectionId], - ), - this.driver.query<{ - tx_id: string - row_version: number - replay_json: string | null - replay_requires_full_reload: number - }>( - `SELECT tx_id, row_version, replay_json, replay_requires_full_reload + [collectionId], + ), + this.driver.query<{ + tx_id: string + row_version: number + replay_json: string | null + replay_requires_full_reload: number + }>( + `SELECT tx_id, row_version, replay_json, replay_requires_full_reload FROM applied_tx WHERE collection_id = ? AND row_version > ? ORDER BY term ASC, seq ASC`, - [collectionId, fromRowVersion], - ), - ]) + [collectionId, fromRowVersion], + ), + this.driver.query<{ min_row_version: number | null }>( + `SELECT MIN(row_version) AS min_row_version + FROM applied_tx + WHERE collection_id = ?`, + [collectionId], + ), + ]) const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0 + const replayFloor = replayAvailabilityRows[0]?.min_row_version + if ( + latestRowVersion > fromRowVersion && + (replayFloor == null || replayFloor > fromRowVersion + 1) + ) { + return { + latestRowVersion, + requiresFullReload: true, + } + } + const changedKeyCount = changedRows.length + deletedRows.length if (changedKeyCount > this.pullSinceReloadThreshold) { diff --git a/packages/expo-db-sqlite-persistence/src/expo.ts b/packages/expo-db-sqlite-persistence/src/expo.ts index 53cf16e0e..bdb490d21 100644 --- a/packages/expo-db-sqlite-persistence/src/expo.ts +++ b/packages/expo-db-sqlite-persistence/src/expo.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -77,8 +79,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/expo-db-sqlite-persistence/src/index.ts b/packages/expo-db-sqlite-persistence/src/index.ts index 6b6968dca..0f23fd540 100644 --- a/packages/expo-db-sqlite-persistence/src/index.ts +++ b/packages/expo-db-sqlite-persistence/src/index.ts @@ -4,7 +4,11 @@ export type { ExpoSQLitePersistenceOptions, ExpoSQLiteSchemaMismatchPolicy, } from './expo' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/node-db-sqlite-persistence/README.md b/packages/node-db-sqlite-persistence/README.md index fc2fdb2f6..23fc70836 100644 --- a/packages/node-db-sqlite-persistence/README.md +++ b/packages/node-db-sqlite-persistence/README.md @@ -47,3 +47,32 @@ export const todosCollection = createCollection( mode-specific behavior (`sync-present` vs `sync-absent`) automatically. - `schemaVersion` is specified per collection via `persistedCollectionOptions`. - Call `database.close()` when your app shuts down. + +## Applied transaction pruning + +The `applied_tx` log is a replayable cache, so it is pruned by default to keep +the SQLite file from growing without bound. When you don't pass prune options, +the node driver applies: + +- `appliedTxPruneMaxRows: 1_000` (per-collection row cap) +- `appliedTxPruneMaxAgeSeconds: 86_400` (24h age backstop) + +Pruning runs inside each write transaction, so every collection self-trims on +its next sync. If a process asks to recover from a point older than the retained +replay window, recovery falls back to a full reload. Override either value to +tune retention, or pass `0` to disable that limit: + +```ts +const persistence = createNodeSQLitePersistence({ + database, + appliedTxPruneMaxRows: 5_000, // higher row cap + appliedTxPruneMaxAgeSeconds: 0, // disable the age backstop +}) +``` + +Pruning removes rows from `applied_tx`, but SQLite may not immediately shrink +the database file on disk. Use `VACUUM`, `auto_vacuum`, or your own maintenance +process if you need to reclaim disk space. + +The defaults are exported as `DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS` and +`DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS`. diff --git a/packages/node-db-sqlite-persistence/src/index.ts b/packages/node-db-sqlite-persistence/src/index.ts index 52c936c1d..0a3a703b3 100644 --- a/packages/node-db-sqlite-persistence/src/index.ts +++ b/packages/node-db-sqlite-persistence/src/index.ts @@ -1,4 +1,8 @@ -export { createNodeSQLitePersistence } from './node-persistence' +export { + createNodeSQLitePersistence, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, +} from './node-persistence' export type { BetterSqlite3Database, NodeSQLitePersistenceOptions, diff --git a/packages/node-db-sqlite-persistence/src/node-persistence.ts b/packages/node-db-sqlite-persistence/src/node-persistence.ts index 2dbd718c3..4635b9c65 100644 --- a/packages/node-db-sqlite-persistence/src/node-persistence.ts +++ b/packages/node-db-sqlite-persistence/src/node-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS as CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS as CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -35,6 +37,22 @@ type NodeSQLitePersistenceBaseOptions = Omit< export type NodeSQLitePersistenceOptions = NodeSQLitePersistenceBaseOptions +/** + * Default cap on retained `applied_tx` rows per collection. The log is a + * replayable cache, so a bounded row count keeps SQLite files from growing + * without limit. Pass `appliedTxPruneMaxRows: 0` to disable the row cap. + */ +export const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = + CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS + +/** + * Default age backstop for retained `applied_tx` rows, in seconds (24h). Rows + * older than this are pruned on the next write. Pass + * `appliedTxPruneMaxAgeSeconds: 0` to disable the age backstop. + */ +export const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = + CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS + function normalizeSchemaMismatchPolicy( policy: NodeSQLiteSchemaMismatchPolicy, ): NodeSQLiteCoreSchemaMismatchPolicy { @@ -81,8 +99,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + CORE_DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts b/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts index 12fc53f93..15669b7b8 100644 --- a/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts +++ b/packages/node-db-sqlite-persistence/tests/node-persistence.test.ts @@ -7,6 +7,7 @@ import { createNodeSQLitePersistence, persistedCollectionOptions } from '../src' import { BetterSqlite3SQLiteDriver } from '../src/node-driver' import { SingleProcessCoordinator } from '../../db-sqlite-persistence-core/src' import { runRuntimePersistenceContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract' +import type { SQLitePullSinceResult } from '../../db-sqlite-persistence-core/src' import type { RuntimePersistenceContractTodo, RuntimePersistenceDatabaseHarness, @@ -127,6 +128,208 @@ describe(`node persistence helpers`, () => { } }) + it(`prunes applied_tx rows past the default age backstop`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-default-prune-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `default-prune` + const database = new BetterSqlite3(dbPath) + + try { + const persistence = createNodeSQLitePersistence({ database }) + + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { id: `1`, title: `old`, score: 1 }, + }, + ], + }) + + // Backdate the first row well beyond the 24h default age backstop. + database + .prepare( + `UPDATE applied_tx SET applied_at = 0 WHERE collection_id = ? AND seq = 1`, + ) + .run(collectionId) + + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-2`, + term: 1, + seq: 2, + rowVersion: 2, + mutations: [ + { + type: `insert`, + key: `2`, + value: { id: `2`, title: `new`, score: 2 }, + }, + ], + }) + + const appliedRows = database + .prepare( + `SELECT seq FROM applied_tx WHERE collection_id = ? ORDER BY seq ASC`, + ) + .all(collectionId) as Array<{ seq: number }> + expect(appliedRows.map((row) => row.seq)).toEqual([2]) + } finally { + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + + it(`forces full reload when pullSince starts before pruned replay rows`, async () => { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-node-pruned-pull-since-`), + ) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `pruned-pull-since` + const database = new BetterSqlite3(dbPath) + + try { + const persistence = createNodeSQLitePersistence({ + database, + appliedTxPruneMaxRows: 2, + appliedTxPruneMaxAgeSeconds: 0, + }) + + for (const seq of [1, 2, 3]) { + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-${seq}`, + term: 1, + seq, + rowVersion: seq, + mutations: [ + { + type: `insert`, + key: String(seq), + value: { id: String(seq), title: `todo-${seq}`, score: seq }, + }, + ], + }) + } + + const adapter = persistence.adapter as typeof persistence.adapter & { + pullSince: ( + collectionId: string, + fromRowVersion: number, + ) => Promise> + } + const result = await adapter.pullSince(collectionId, 0) + + expect(result.requiresFullReload).toBe(true) + } finally { + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + + it(`prunes applied_tx rows past explicit row cap`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-row-prune-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `row-prune` + const database = new BetterSqlite3(dbPath) + + try { + const persistence = createNodeSQLitePersistence({ + database, + appliedTxPruneMaxRows: 2, + appliedTxPruneMaxAgeSeconds: 0, + }) + + for (const seq of [1, 2, 3]) { + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-${seq}`, + term: 1, + seq, + rowVersion: seq, + mutations: [ + { + type: `insert`, + key: String(seq), + value: { id: String(seq), title: `todo-${seq}`, score: seq }, + }, + ], + }) + } + + const appliedRows = database + .prepare( + `SELECT seq FROM applied_tx WHERE collection_id = ? ORDER BY seq ASC`, + ) + .all(collectionId) as Array<{ seq: number }> + expect(appliedRows.map((row) => row.seq)).toEqual([2, 3]) + } finally { + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + + it(`leaves applied_tx rows untouched when pruning is disabled`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-no-prune-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `no-prune` + const database = new BetterSqlite3(dbPath) + + try { + const persistence = createNodeSQLitePersistence({ + database, + appliedTxPruneMaxRows: 0, + appliedTxPruneMaxAgeSeconds: 0, + }) + + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { id: `1`, title: `old`, score: 1 }, + }, + ], + }) + + database + .prepare( + `UPDATE applied_tx SET applied_at = 0 WHERE collection_id = ? AND seq = 1`, + ) + .run(collectionId) + + await persistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-2`, + term: 1, + seq: 2, + rowVersion: 2, + mutations: [ + { + type: `insert`, + key: `2`, + value: { id: `2`, title: `new`, score: 2 }, + }, + ], + }) + + const appliedRows = database + .prepare( + `SELECT seq FROM applied_tx WHERE collection_id = ? ORDER BY seq ASC`, + ) + .all(collectionId) as Array<{ seq: number }> + expect(appliedRows.map((row) => row.seq)).toEqual([1, 2]) + } finally { + database.close() + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) + it(`infers schema policy from sync mode`, async () => { const tempDirectory = mkdtempSync(join(tmpdir(), `db-node-schema-infer-`)) const dbPath = join(tempDirectory, `state.sqlite`) diff --git a/packages/react-native-db-sqlite-persistence/src/index.ts b/packages/react-native-db-sqlite-persistence/src/index.ts index 842298336..8dc1b4aeb 100644 --- a/packages/react-native-db-sqlite-persistence/src/index.ts +++ b/packages/react-native-db-sqlite-persistence/src/index.ts @@ -4,7 +4,11 @@ export type { ReactNativeSQLitePersistenceOptions, ReactNativeSQLiteSchemaMismatchPolicy, } from './react-native' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts b/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts index 073994f38..7fce7b436 100644 --- a/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts +++ b/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -79,8 +81,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } } diff --git a/packages/tauri-db-sqlite-persistence/src/index.ts b/packages/tauri-db-sqlite-persistence/src/index.ts index 7a750708d..e9ebfc7f3 100644 --- a/packages/tauri-db-sqlite-persistence/src/index.ts +++ b/packages/tauri-db-sqlite-persistence/src/index.ts @@ -4,7 +4,11 @@ export type { TauriSQLitePersistenceOptions, TauriSQLiteSchemaMismatchPolicy, } from './tauri' -export { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' +export { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + persistedCollectionOptions, +} from '@tanstack/db-sqlite-persistence-core' export type { PersistedCollectionCoordinator, PersistedCollectionPersistence, diff --git a/packages/tauri-db-sqlite-persistence/src/tauri-persistence.ts b/packages/tauri-db-sqlite-persistence/src/tauri-persistence.ts index 956f64dfd..f3dea0d17 100644 --- a/packages/tauri-db-sqlite-persistence/src/tauri-persistence.ts +++ b/packages/tauri-db-sqlite-persistence/src/tauri-persistence.ts @@ -1,4 +1,6 @@ import { + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, + DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, SingleProcessCoordinator, createSQLiteCorePersistenceAdapter, } from '@tanstack/db-sqlite-persistence-core' @@ -91,8 +93,11 @@ function resolveAdapterBaseOptions( `driver` | `schemaVersion` | `schemaMismatchPolicy` > { return { - appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, - appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + appliedTxPruneMaxRows: + options.appliedTxPruneMaxRows ?? DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS, + appliedTxPruneMaxAgeSeconds: + options.appliedTxPruneMaxAgeSeconds ?? + DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS, pullSinceReloadThreshold: options.pullSinceReloadThreshold, } }