From a5b5082c45c15ec639ff9e969fa72126bab29d2c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 20:54:11 +0100 Subject: [PATCH 1/2] fix(sqlite-persistence): accept Tauri string migration errors --- .../fix-tauri-string-migration-errors.md | 6 + .../src/sqlite-core-adapter.ts | 19 +- .../tests/tauri-persistence.test.ts | 175 ++++++++++++++++++ 3 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-tauri-string-migration-errors.md diff --git a/.changeset/fix-tauri-string-migration-errors.md b/.changeset/fix-tauri-string-migration-errors.md new file mode 100644 index 0000000000..b665ec1f9f --- /dev/null +++ b/.changeset/fix-tauri-string-migration-errors.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +'@tanstack/tauri-db-sqlite-persistence': patch +--- + +Allow idempotent SQLite column migrations to recognize Tauri SQL duplicate-column errors returned as strings while continuing to reject unrelated migration failures. 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 69f29fc604..2e9fa9bbb4 100644 --- a/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts +++ b/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts @@ -121,17 +121,24 @@ function isDuplicateColumnAddError( error: unknown, columnName: string, ): boolean { - if (!(error instanceof Error)) { + const message = + typeof error === `string` + ? error + : error instanceof Error + ? error.message + : undefined + + if (message === undefined) { return false } - const message = error.message.toLowerCase() + const normalizedMessage = message.toLowerCase() const normalizedColumnName = columnName.toLowerCase() return ( - (message.includes(`duplicate column name`) && - message.includes(normalizedColumnName)) || - (message.includes(`already exists`) && - message.includes(normalizedColumnName)) + (normalizedMessage.includes(`duplicate column name`) && + normalizedMessage.includes(normalizedColumnName)) || + (normalizedMessage.includes(`already exists`) && + normalizedMessage.includes(normalizedColumnName)) ) } diff --git a/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts b/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts index da5abe8e99..61baf50586 100644 --- a/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts +++ b/packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts @@ -9,6 +9,7 @@ import { } from '../src' import { createTauriSQLitePersistence as createTauriPersistence } from '../src/tauri' import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' +import type { TauriSQLiteDatabaseLike } from '../src/tauri' type Todo = { id: string @@ -36,6 +37,180 @@ function createTempSqlitePath(): string { return dbPath } +function rethrowDatabaseErrorsAsStrings( + database: TauriSQLiteDatabaseLike, +): TauriSQLiteDatabaseLike { + return { + ...database, + execute: async (...args) => { + try { + return await database.execute(...args) + } catch (error) { + throw error instanceof Error ? error.message : String(error) + } + }, + } +} + +function registerDatabaseCleanup( + database: TauriSQLiteDatabaseLike, +): () => Promise { + let closed = false + const close = async () => { + if (closed) { + return + } + await database.close() + closed = true + } + activeCleanupFns.push(close) + return close +} + +/** + * Migration law (#1711): adding an already-present migration column is an + * idempotent no-op even when the Tauri bridge rejects with a string. Seed a + * real legacy SQLite table, drive migration through the Tauri driver and core + * adapter, then close and reopen once both migration columns exist. PRAGMA, + * exact legacy bytes, and public rows are the checkpoints. The unrelated-error + * control proves the classifier does not turn arbitrary string failures into + * success. This Better SQLite fixture reproduces Tauri's error boundary but is + * not a native Tauri runtime receipt. + */ +it(`migrates and reopens a legacy database when Tauri reports duplicate columns as strings`, async () => { + const dbPath = createTempSqlitePath() + const collectionId = `todos-string-migration` + + const seedDatabase = createTauriSQLiteTestDatabase({ filename: dbPath }) + const closeSeedDatabase = registerDatabaseCleanup(seedDatabase) + await seedDatabase.execute( + `CREATE TABLE applied_tx ( + collection_id TEXT NOT NULL, + term INTEGER NOT NULL, + seq INTEGER NOT NULL, + tx_id TEXT NOT NULL, + row_version INTEGER NOT NULL, + replay_json TEXT, + applied_at INTEGER NOT NULL, + PRIMARY KEY (collection_id, term, seq) + )`, + ) + await seedDatabase.execute( + `INSERT INTO applied_tx ( + collection_id, + term, + seq, + tx_id, + row_version, + replay_json, + applied_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + collectionId, + 1, + 1, + `legacy-tx`, + 1, + `{"mutations":[]}`, + Math.floor(Date.now() / 1000), + ], + ) + await closeSeedDatabase() + + const migrationDatabase = rethrowDatabaseErrorsAsStrings( + createTauriSQLiteTestDatabase({ filename: dbPath }), + ) + const closeMigrationDatabase = registerDatabaseCleanup(migrationDatabase) + const migrationPersistence = createTauriPersistence({ + database: migrationDatabase, + }) + + await migrationPersistence.adapter.applyCommittedTx(collectionId, { + txId: `post-migration-tx`, + term: 1, + seq: 2, + rowVersion: 2, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `Survives migration`, + score: 1, + }, + }, + ], + }) + + const migratedColumns = await migrationDatabase.select< + Array<{ name: string }> + >(`PRAGMA table_info(applied_tx)`) + expect(migratedColumns.map((column) => column.name)).toEqual( + expect.arrayContaining([`replay_json`, `replay_requires_full_reload`]), + ) + expect( + await migrationDatabase.select< + Array<{ tx_id: string; replay_json: string | null }> + >( + `SELECT tx_id, replay_json + FROM applied_tx + WHERE tx_id = 'legacy-tx'`, + ), + ).toEqual([{ tx_id: `legacy-tx`, replay_json: `{"mutations":[]}` }]) + await closeMigrationDatabase() + + const reopenedDatabase = rethrowDatabaseErrorsAsStrings( + createTauriSQLiteTestDatabase({ filename: dbPath }), + ) + registerDatabaseCleanup(reopenedDatabase) + const reopenedPersistence = createTauriPersistence({ + database: reopenedDatabase, + }) + + await expect( + reopenedPersistence.adapter.loadSubset(collectionId, {}), + ).resolves.toEqual([ + { + key: `1`, + value: { + id: `1`, + title: `Survives migration`, + score: 1, + }, + }, + ]) + expect( + await reopenedDatabase.select< + Array<{ tx_id: string; replay_json: string | null }> + >( + `SELECT tx_id, replay_json + FROM applied_tx + WHERE tx_id = 'legacy-tx'`, + ), + ).toEqual([{ tx_id: `legacy-tx`, replay_json: `{"mutations":[]}` }]) +}) + +it(`propagates unrelated string failures from a Tauri migration`, async () => { + const dbPath = createTempSqlitePath() + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + registerDatabaseCleanup(database) + const failingDatabase: TauriSQLiteDatabaseLike = { + ...database, + execute: async (sql, bindValues) => { + if (sql.includes(`ALTER TABLE applied_tx ADD COLUMN replay_json`)) { + throw `database is locked` + } + return database.execute(sql, bindValues) + }, + } + const persistence = createTauriPersistence({ database: failingDatabase }) + + await expect( + persistence.adapter.loadSubset(`todos-unrelated-migration-error`, {}), + ).rejects.toBe(`database is locked`) +}) + it(`persists data across app restart (close and reopen)`, async () => { const dbPath = createTempSqlitePath() const collectionId = `todos-restart` From 3eb59ce71830072ff7c55480ae99f8ac5cc99c4c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 26 Sep 2026 10:34:31 -0600 Subject: [PATCH 2/2] refactor(sqlite-persistence): simplify migration error matching --- .../src/sqlite-core-adapter.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 2e9fa9bbb4..efaacc719e 100644 --- a/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts +++ b/packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts @@ -121,24 +121,17 @@ function isDuplicateColumnAddError( error: unknown, columnName: string, ): boolean { - const message = - typeof error === `string` - ? error - : error instanceof Error - ? error.message - : undefined - - if (message === undefined) { + if (typeof error !== `string` && !(error instanceof Error)) { return false } - const normalizedMessage = message.toLowerCase() - const normalizedColumnName = columnName.toLowerCase() + const normalizedMessage = ( + typeof error === `string` ? error : error.message + ).toLowerCase() return ( - (normalizedMessage.includes(`duplicate column name`) && - normalizedMessage.includes(normalizedColumnName)) || - (normalizedMessage.includes(`already exists`) && - normalizedMessage.includes(normalizedColumnName)) + normalizedMessage.includes(columnName.toLowerCase()) && + (normalizedMessage.includes(`duplicate column name`) || + normalizedMessage.includes(`already exists`)) ) }