Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-tauri-string-migration-errors.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 7 additions & 7 deletions packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,17 @@ function isDuplicateColumnAddError(
error: unknown,
columnName: string,
): boolean {
if (!(error instanceof Error)) {
if (typeof error !== `string` && !(error instanceof Error)) {
return false
}

const message = error.message.toLowerCase()
const normalizedColumnName = columnName.toLowerCase()
const normalizedMessage = (
typeof error === `string` ? error : error.message
).toLowerCase()
return (
(message.includes(`duplicate column name`) &&
message.includes(normalizedColumnName)) ||
(message.includes(`already exists`) &&
message.includes(normalizedColumnName))
normalizedMessage.includes(columnName.toLowerCase()) &&
(normalizedMessage.includes(`duplicate column name`) ||
normalizedMessage.includes(`already exists`))
)
}

Expand Down
175 changes: 175 additions & 0 deletions packages/tauri-db-sqlite-persistence/tests/tauri-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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`
Expand Down
Loading