diff --git a/packages/db-tauri-sqlite-persisted-collection/README.md b/packages/db-tauri-sqlite-persisted-collection/README.md new file mode 100644 index 000000000..3c2c6b32f --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/README.md @@ -0,0 +1,96 @@ +# @tanstack/db-tauri-sqlite-persisted-collection + +Thin SQLite persistence for Tauri apps using `@tauri-apps/plugin-sql`. + +## Public API + +- `createTauriSQLitePersistence(...)` +- `persistedCollectionOptions(...)` (re-exported from core) + +## Install + +```bash +pnpm add @tanstack/db-tauri-sqlite-persisted-collection @tauri-apps/plugin-sql +``` + +## Consumer-side Tauri setup + +Install the official SQL plugin in your Tauri app: + +```bash +cd src-tauri +cargo add tauri-plugin-sql --features sqlite +``` + +Register the plugin in `src-tauri/src/main.rs`: + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_sql::Builder::default().build()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Enable the SQL permissions in `src-tauri/capabilities/default.json`: + +```json +{ + "permissions": ["core:default", "sql:default", "sql:allow-execute"] +} +``` + +## Quick start + +```ts +import Database from '@tauri-apps/plugin-sql' +import { createCollection } from '@tanstack/db' +import { + createTauriSQLitePersistence, + persistedCollectionOptions, +} from '@tanstack/db-tauri-sqlite-persisted-collection' + +type Todo = { + id: string + title: string + completed: boolean +} + +const database = await Database.load(`sqlite:tanstack-db.sqlite`) + +const persistence = createTauriSQLitePersistence({ + database, +}) + +export const todosCollection = createCollection( + persistedCollectionOptions({ + id: `todos`, + getKey: (todo) => todo.id, + persistence, + schemaVersion: 1, + }), +) +``` + +## Notes + +- `createTauriSQLitePersistence` is shared across collections. +- Reuse a single `Database.load('sqlite:...')` handle per SQLite file when using + this package. Opening multiple plugin handles to the same file can reintroduce + SQLite locking behavior outside this package's serialized transaction queue. +- Mode defaults (`sync-present` vs `sync-absent`) are inferred from whether a + `sync` config is present in `persistedCollectionOptions`. +- This package expects a database handle created by + `@tauri-apps/plugin-sql`, typically from `Database.load('sqlite:...')`. +- The database path is resolved by Tauri's SQL plugin, not by this package. +- This package does not publish or require package-specific Rust code. Only the + app-level Tauri SQL plugin registration shown above is required. + +## Testing + +- `pnpm --filter @tanstack/db-tauri-sqlite-persisted-collection test` + runs the driver and shared adapter contract tests. +- `pnpm --filter @tanstack/db-tauri-sqlite-persisted-collection test:e2e` + builds the repo-local Tauri harness and runs the persisted collection + conformance suite inside a real Tauri runtime. diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/index.html b/packages/db-tauri-sqlite-persisted-collection/e2e/app/index.html new file mode 100644 index 000000000..3b6202ebf --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/index.html @@ -0,0 +1,48 @@ + + + + + + Tauri SQLite E2E + + +
+

Booting Tauri persisted collection e2e runtime

+

+    
+ + + diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/package.json b/packages/db-tauri-sqlite-persisted-collection/e2e/app/package.json new file mode 100644 index 000000000..053802d95 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/package.json @@ -0,0 +1,23 @@ +{ + "name": "@tanstack/db-tauri-sqlite-persisted-collection-e2e-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-sql": "^2.3.2", + "@tanstack/db": "workspace:*", + "@tanstack/db-tauri-sqlite-persisted-collection": "workspace:*" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.10.1", + "@types/node": "^25.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/.gitignore b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/.gitignore new file mode 100644 index 000000000..9aa3edd9a --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/.gitignore @@ -0,0 +1,3 @@ +/target +/gen +/Cargo.lock diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/Cargo.toml b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/Cargo.toml new file mode 100644 index 000000000..719913622 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "db-tauri-sqlite-persisted-collection-e2e-app" +version = "0.0.0" +description = "Repo-local Tauri e2e harness for TanStack DB SQLite persistence" +authors = ["TanStack Team"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2.5.6", features = [] } + +[dependencies] +serde_json = "1" +tauri = { version = "2.10.3", features = [] } +tauri-plugin-sql = { version = "2.3.2", features = ["sqlite"] } diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/build.rs b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/build.rs new file mode 100644 index 000000000..795b9b7c8 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/capabilities/default.json b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/capabilities/default.json new file mode 100644 index 000000000..7b7399a77 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/capabilities/default.json @@ -0,0 +1,6 @@ +{ + "identifier": "default", + "description": "Default capability for the Tauri SQLite e2e harness", + "windows": ["main"], + "permissions": ["core:default", "sql:default", "sql:allow-execute"] +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/icons/icon.png b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/icons/icon.png new file mode 100644 index 000000000..cf5d58995 Binary files /dev/null and b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/icons/icon.png differ diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/src/main.rs b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/src/main.rs new file mode 100644 index 000000000..badb74737 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/src/main.rs @@ -0,0 +1,8 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_sql::Builder::default().build()) + .run(tauri::generate_context!()) + .expect("error while running Tauri e2e application"); +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/tauri.conf.json b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/tauri.conf.json new file mode 100644 index 000000000..6f0470331 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src-tauri/tauri.conf.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "TanStack DB Tauri E2E", + "version": "0.0.0", + "identifier": "com.tanstack.db.tauri.e2e", + "build": { + "beforeDevCommand": "pnpm dev --host 127.0.0.1 --port 1420", + "devUrl": "http://127.0.0.1:1420", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "TanStack DB Tauri E2E", + "width": 1280, + "height": 900 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "icon": [] + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/main.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/main.ts new file mode 100644 index 000000000..f1d266d96 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/main.ts @@ -0,0 +1,139 @@ +import { createNativeTauriSQLiteTestDatabase } from './native-tauri-sql-test-db' +import { registerTauriNativeE2ESuite } from './register-tauri-e2e-suite' +import { + getRegisteredTestCount, + resetRegisteredTests, + runRegisteredTests, +} from './runtime-vitest' + +const statusElement = document.querySelector(`#status`) as HTMLParagraphElement +const detailsElement = document.querySelector(`#details`) as HTMLPreElement +const runtimeRunId = + import.meta.env.VITE_TANSTACK_DB_TAURI_E2E_RUN_ID ?? Date.now().toString(36) +const reportUrl = import.meta.env.VITE_TANSTACK_DB_TAURI_E2E_REPORT_URL + +function setStatus(status: string, details?: unknown): void { + statusElement.textContent = status + if (details !== undefined) { + detailsElement.textContent = JSON.stringify(details, null, 2) + } +} + +async function postHarnessMessage( + payload: Record, +): Promise { + if (!reportUrl) { + return + } + + await fetch(reportUrl, { + method: `POST`, + headers: { + 'content-type': `application/json`, + }, + body: JSON.stringify(payload), + }) +} + +async function reportRunResult(result: { + status: `passed` | `failed` + payload: unknown +}): Promise { + await postHarnessMessage({ + kind: `result`, + ...result, + }) +} + +async function reportStatus(phase: string, details?: unknown): Promise { + await postHarnessMessage({ + kind: `status`, + phase, + details, + }) +} + +async function run(): Promise { + setStatus(`Starting Tauri e2e runtime`) + + try { + await reportStatus(`starting`, { runId: runtimeRunId }) + const database = await createNativeTauriSQLiteTestDatabase({ + runId: runtimeRunId, + }) + await reportStatus(`database-loaded`) + + resetRegisteredTests() + registerTauriNativeE2ESuite({ + suiteName: `tauri persisted collection conformance`, + database, + runId: runtimeRunId, + }) + await reportStatus(`suite-registered`) + + const totalTests = getRegisteredTestCount() + setStatus(`Running Tauri e2e suite`, { + totalTests, + runId: runtimeRunId, + }) + await reportStatus(`tests-starting`, { + totalTests, + }) + + const result = await runRegisteredTests({ + onTestStart: ({ index, name, total }) => { + setStatus(`Running test ${String(index)}/${String(total)}`, { + currentTest: name, + }) + }, + }) + await reportStatus(`tests-finished`, { + total: result.total, + failed: result.failed, + }) + + const failedResults = result.results.filter( + (entry) => entry.status === `failed`, + ) + const summary = { + passed: result.passed, + failed: result.failed, + skipped: result.skipped, + total: result.total, + failures: failedResults.slice(0, 10), + } + + if (result.failed > 0) { + await reportRunResult({ + status: `failed`, + payload: summary, + }) + setStatus(`Tauri e2e failed`, summary) + return + } + + await reportRunResult({ + status: `passed`, + payload: summary, + }) + setStatus(`Tauri e2e passed`, summary) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const payload = { + error: message, + runId: runtimeRunId, + step: statusElement.textContent, + } + + try { + await reportRunResult({ + status: `failed`, + payload, + }) + } catch {} + + setStatus(`Tauri e2e failed: ${message}`, payload) + } +} + +void run() diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/native-tauri-sql-test-db.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/native-tauri-sql-test-db.ts new file mode 100644 index 000000000..09f849b62 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/native-tauri-sql-test-db.ts @@ -0,0 +1,8 @@ +import Database from '@tauri-apps/plugin-sql' +import type { TauriSQLiteDatabaseLike } from '../../../src' + +export async function createNativeTauriSQLiteTestDatabase(options: { + runId: string +}): Promise { + return Database.load(`sqlite:tanstack_db_tauri_e2e_${options.runId}.db`) +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/node-crypto.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/node-crypto.ts new file mode 100644 index 000000000..e2051e496 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/node-crypto.ts @@ -0,0 +1,3 @@ +export function randomUUID(): string { + return globalThis.crypto.randomUUID() +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/register-tauri-e2e-suite.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/register-tauri-e2e-suite.ts new file mode 100644 index 000000000..7a14029d6 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/register-tauri-e2e-suite.ts @@ -0,0 +1,30 @@ +import { createTauriSQLitePersistence } from '../../../src' +import { createTauriPersistedCollectionHarnessConfig } from '../../shared/tauri-persisted-collection-harness' +import { registerPersistedCollectionConformanceSuite } from '../../shared/register-persisted-collection-conformance-suite' +import type { PersistedCollectionPersistence } from '@tanstack/db-sqlite-persisted-collection-core' +import type { TauriSQLiteDatabaseLike } from '../../../src' + +type PersistableRow = { + id: string +} + +export function registerTauriNativeE2ESuite(options: { + suiteName: string + database: TauriSQLiteDatabaseLike + runId: string +}): void { + registerPersistedCollectionConformanceSuite({ + suiteName: options.suiteName, + createHarness: () => + createTauriPersistedCollectionHarnessConfig({ + database: options.database, + suiteId: options.runId, + createPersistence: ( + database: TauriSQLiteDatabaseLike, + ): PersistedCollectionPersistence => + createTauriSQLitePersistence({ + database, + }), + }), + }) +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/runtime-vitest.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/runtime-vitest.ts new file mode 100644 index 000000000..f83972223 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/runtime-vitest.ts @@ -0,0 +1,545 @@ +type AsyncCallback = () => void | Promise + +type TestNode = { + name: string + fn: AsyncCallback + skipped: boolean +} + +type SuiteNode = { + name: string + suites: Array + tests: Array + beforeAllHooks: Array + afterAllHooks: Array + beforeEachHooks: Array + afterEachHooks: Array + skipped: boolean +} + +type TestResult = { + name: string + status: `passed` | `failed` | `skipped` + error?: string +} + +export type RegisteredTestRunResult = { + passed: number + failed: number + skipped: number + total: number + results: Array +} + +function createSuite(name: string, skipped = false): SuiteNode { + return { + name, + suites: [], + tests: [], + beforeAllHooks: [], + afterAllHooks: [], + beforeEachHooks: [], + afterEachHooks: [], + skipped, + } +} + +const rootSuite = createSuite(``) +function formatSuitePath( + suites: ReadonlyArray, + leafName?: string, +): string { + const segments = suites + .map((suite) => suite.name) + .filter((name) => name.length > 0) + if (leafName && leafName.length > 0) { + segments.push(leafName) + } + + return segments.join(` > `) +} + +let suiteStack: Array = [rootSuite] + +function currentSuite(): SuiteNode { + return suiteStack[suiteStack.length - 1] ?? rootSuite +} + +function pushSuite( + name: string, + skipped: boolean, + callback: AsyncCallback, +): void { + const suite = createSuite(name, skipped) + currentSuite().suites.push(suite) + + if (skipped) { + return + } + + suiteStack.push(suite) + try { + callback() + } finally { + suiteStack.pop() + } +} + +function registerHook( + key: + | `beforeAllHooks` + | `afterAllHooks` + | `beforeEachHooks` + | `afterEachHooks`, + callback: AsyncCallback, +): void { + currentSuite()[key].push(callback) +} + +function resolveTestCallback( + callbackOrOptions: AsyncCallback | Record, + maybeCallback?: AsyncCallback, +): AsyncCallback { + if (typeof callbackOrOptions === `function`) { + return callbackOrOptions + } + + if (typeof maybeCallback === `function`) { + return maybeCallback + } + + throw new Error(`Test callback must be a function`) +} + +function registerTest( + name: string, + callback: AsyncCallback, + skipped: boolean, +): void { + currentSuite().tests.push({ + name, + fn: callback, + skipped, + }) +} + +function formatValue(value: unknown): string { + if (typeof value === `string`) { + return value + } + + return JSON.stringify( + value, + (_, nestedValue) => + typeof nestedValue === `bigint` + ? { __type: `bigint`, value: nestedValue.toString() } + : nestedValue, + 2, + ) +} + +function isObjectLike(value: unknown): value is Record { + return typeof value === `object` && value !== null +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true + } + + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime() + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ) + } + + if (isObjectLike(left) && isObjectLike(right)) { + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(right, key) && + deepEqual(left[key], right[key]), + ) + ) + } + + return false +} + +function failExpectation(message: string | undefined, fallback: string): never { + throw new Error(message ?? fallback) +} + +function createMatchers( + actual: unknown, + message?: string, + negate = false, +): Record { + const assert = (condition: boolean, failureMessage: string): void => { + const shouldFail = negate ? condition : !condition + if (shouldFail) { + failExpectation(message, failureMessage) + } + } + + const matchers = { + toBe(expected: unknown) { + assert( + Object.is(actual, expected), + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be ${formatValue(expected)}`, + ) + }, + toEqual(expected: unknown) { + assert( + deepEqual(actual, expected), + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to equal ${formatValue(expected)}`, + ) + }, + toBeGreaterThan(expected: number) { + assert( + typeof actual === `number` && actual > expected, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be greater than ${String(expected)}`, + ) + }, + toBeGreaterThanOrEqual(expected: number) { + assert( + typeof actual === `number` && actual >= expected, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be greater than or equal to ${String(expected)}`, + ) + }, + toBeLessThan(expected: number) { + assert( + typeof actual === `number` && actual < expected, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be less than ${String(expected)}`, + ) + }, + toBeLessThanOrEqual(expected: number) { + assert( + typeof actual === `number` && actual <= expected, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be less than or equal to ${String(expected)}`, + ) + }, + toBeTruthy() { + assert( + Boolean(actual), + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be truthy`, + ) + }, + toBeDefined() { + assert( + actual !== undefined, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be defined`, + ) + }, + toBeNull() { + assert( + actual === null, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to be null`, + ) + }, + toContain(expected: unknown) { + const contains = + typeof actual === `string` + ? actual.includes(String(expected)) + : Array.isArray(actual) + ? actual.some((entry) => deepEqual(entry, expected)) + : false + + assert( + contains, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to contain ${formatValue(expected)}`, + ) + }, + toHaveProperty(propertyKey: string) { + const hasProperty = + isObjectLike(actual) && + Object.prototype.hasOwnProperty.call(actual, propertyKey) + + assert( + hasProperty, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to have property ${propertyKey}`, + ) + }, + toHaveLength(expected: number) { + const actualLength = + typeof actual === `string` || Array.isArray(actual) + ? actual.length + : undefined + + assert( + actualLength === expected, + `Expected ${formatValue(actual)} ${negate ? `not ` : ``}to have length ${String(expected)}`, + ) + }, + } as Record + + return new Proxy(matchers, { + get(target, propertyKey, receiver) { + if (propertyKey === `not`) { + return createMatchers(actual, message, !negate) + } + + return Reflect.get(target, propertyKey, receiver) + }, + }) +} + +export function expect( + actual: unknown, + message?: string, +): Record { + return createMatchers(actual, message) +} + +type Describe = ((name: string, callback: AsyncCallback) => void) & { + skip: (name: string, callback: AsyncCallback) => void +} + +type It = (( + name: string, + callbackOrOptions: AsyncCallback | Record, + maybeCallback?: AsyncCallback, +) => void) & { + skip: ( + name: string, + callbackOrOptions: AsyncCallback | Record, + maybeCallback?: AsyncCallback, + ) => void +} + +export const describe: Describe = Object.assign( + (name: string, callback: AsyncCallback) => { + pushSuite(name, false, callback) + }, + { + skip: (name: string, callback: AsyncCallback) => { + pushSuite(name, true, callback) + }, + }, +) + +export const it: It = Object.assign( + ( + name: string, + callbackOrOptions: AsyncCallback | Record, + maybeCallback?: AsyncCallback, + ) => { + registerTest( + name, + resolveTestCallback(callbackOrOptions, maybeCallback), + false, + ) + }, + { + skip: ( + name: string, + callbackOrOptions: AsyncCallback | Record, + maybeCallback?: AsyncCallback, + ) => { + registerTest( + name, + resolveTestCallback(callbackOrOptions, maybeCallback), + true, + ) + }, + }, +) + +export const test = it + +export function beforeAll(callback: AsyncCallback): void { + registerHook(`beforeAllHooks`, callback) +} + +export function afterAll(callback: AsyncCallback): void { + registerHook(`afterAllHooks`, callback) +} + +export function beforeEach(callback: AsyncCallback): void { + registerHook(`beforeEachHooks`, callback) +} + +export function afterEach(callback: AsyncCallback): void { + registerHook(`afterEachHooks`, callback) +} + +export function resetRegisteredTests(): void { + rootSuite.suites = [] + rootSuite.tests = [] + rootSuite.beforeAllHooks = [] + rootSuite.afterAllHooks = [] + rootSuite.beforeEachHooks = [] + rootSuite.afterEachHooks = [] + suiteStack = [rootSuite] +} + +function countTests(suite: SuiteNode): number { + return ( + suite.tests.length + + suite.suites.reduce( + (count, childSuite) => count + countTests(childSuite), + 0, + ) + ) +} + +export function getRegisteredTestCount(): number { + return countTests(rootSuite) +} + +async function runHookList( + hooks: ReadonlyArray, + label: string, + results: Array, +): Promise { + for (const hook of hooks) { + try { + await hook() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + results.push({ + name: label, + status: `failed`, + error: message, + }) + return false + } + } + + return true +} + +async function runSuite( + suite: SuiteNode, + ancestors: Array, + results: Array, + state: { + index: number + total: number + }, + options: { + onTestStart?: (context: { + name: string + index: number + total: number + }) => void + }, +): Promise { + if (suite.skipped) { + return + } + + const suitePath = [...ancestors, suite] + const beforeAllSucceeded = await runHookList( + suite.beforeAllHooks, + `${formatSuitePath(suitePath)} beforeAll`, + results, + ) + + if (beforeAllSucceeded) { + for (const testNode of suite.tests) { + const testName = formatSuitePath(suitePath, testNode.name) + state.index++ + + if (testNode.skipped) { + results.push({ + name: testName, + status: `skipped`, + }) + continue + } + + options.onTestStart?.({ + name: testName, + index: state.index, + total: state.total, + }) + + try { + for (const entry of suitePath) { + for (const hook of entry.beforeEachHooks) { + await hook() + } + } + + await testNode.fn() + results.push({ + name: testName, + status: `passed`, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + results.push({ + name: testName, + status: `failed`, + error: message, + }) + } finally { + for (const entry of [...suitePath].reverse()) { + for (const hook of entry.afterEachHooks) { + try { + await hook() + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + results.push({ + name: `${testName} afterEach`, + status: `failed`, + error: message, + }) + } + } + } + } + } + + for (const childSuite of suite.suites) { + await runSuite(childSuite, suitePath, results, state, options) + } + } + + await runHookList( + suite.afterAllHooks, + `${formatSuitePath(suitePath)} afterAll`, + results, + ) +} + +export async function runRegisteredTests( + options: { + onTestStart?: (context: { + name: string + index: number + total: number + }) => void + } = {}, +): Promise { + const results: Array = [] + const state = { + index: 0, + total: getRegisteredTestCount(), + } + + await runSuite(rootSuite, [], results, state, options) + + const passed = results.filter((result) => result.status === `passed`).length + const failed = results.filter((result) => result.status === `failed`).length + const skipped = results.filter((result) => result.status === `skipped`).length + + return { + passed, + failed, + skipped, + total: results.length, + results, + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/vite-env.d.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/tsconfig.json b/packages/db-tauri-sqlite-persisted-collection/e2e/app/tsconfig.json new file mode 100644 index 000000000..9b5a08fdf --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "allowJs": false, + "checkJs": false, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src", "vite.config.ts"], + "exclude": ["dist", "node_modules", "src-tauri/target"] +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/app/vite.config.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/app/vite.config.ts new file mode 100644 index 000000000..fb33e888a --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/app/vite.config.ts @@ -0,0 +1,28 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +const appDirectory = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + vitest: resolve(appDirectory, `src/runtime-vitest.ts`), + 'node:crypto': resolve(appDirectory, `src/node-crypto.ts`), + '@tanstack/db': resolve(appDirectory, `../../../db/src`), + '@tanstack/db-ivm': resolve(appDirectory, `../../../db-ivm/src`), + '@tanstack/db-sqlite-persisted-collection-core': resolve( + appDirectory, + `../../../db-sqlite-persisted-collection-core/src`, + ), + }, + }, + server: { + host: `127.0.0.1`, + port: 1420, + strictPort: true, + }, + build: { + target: `es2022`, + }, +}) diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/run-tauri-e2e.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/run-tauri-e2e.ts new file mode 100644 index 000000000..8389e3f92 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/run-tauri-e2e.ts @@ -0,0 +1,166 @@ +import { createServer } from 'node:http' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { spawn } from 'node:child_process' + +type TauriE2ERunResult = + | { + kind: `result` + status: `passed` + payload: unknown + } + | { + kind: `result` + status: `failed` + payload: unknown + } + +function isTauriE2ERunResult( + value: + | TauriE2ERunResult + | { kind: `status`; phase: string; details?: unknown } + | null, +): value is TauriE2ERunResult { + return value?.kind === `result` +} + +function createOutputCollector() { + let output = `` + + return { + append(chunk: string | Buffer) { + output += chunk.toString() + if (output.length > 20_000) { + output = output.slice(-20_000) + } + }, + getOutput() { + return output + }, + } +} + +export async function runTauriPersistedCollectionE2E(options?: { + timeoutMs?: number +}): Promise { + const timeoutMs = options?.timeoutMs ?? 180_000 + const runId = Date.now().toString(36) + const appDirectory = join(process.cwd(), `e2e`, `app`) + let latestMessage: + | TauriE2ERunResult + | { + kind: `status` + phase: string + details?: unknown + } + | null = null + + const reportServer = createServer((request, response) => { + response.setHeader(`Access-Control-Allow-Origin`, `*`) + response.setHeader(`Access-Control-Allow-Methods`, `POST, OPTIONS`) + response.setHeader(`Access-Control-Allow-Headers`, `content-type`) + + if (request.method === `OPTIONS`) { + response.statusCode = 204 + response.end() + return + } + + if (request.method !== `POST`) { + response.statusCode = 404 + response.end() + return + } + + const chunks: Array = [] + request.on(`data`, (chunk) => { + chunks.push(Buffer.from(chunk)) + }) + request.on(`end`, () => { + try { + latestMessage = JSON.parse(Buffer.concat(chunks).toString(`utf8`)) as + | TauriE2ERunResult + | { + kind: `status` + phase: string + details?: unknown + } + response.statusCode = 204 + response.end() + } catch { + response.statusCode = 400 + response.end() + } + }) + }) + + await new Promise((resolve, reject) => { + reportServer.listen(0, `127.0.0.1`, () => resolve()) + reportServer.once(`error`, reject) + }) + + const reportPort = ( + reportServer.address() as { + port: number + } + ).port + const reportUrl = `http://127.0.0.1:${String(reportPort)}` + + const stdoutCollector = createOutputCollector() + const stderrCollector = createOutputCollector() + + const child = spawn(`pnpm`, [`exec`, `tauri`, `dev`, `--no-watch`], { + cwd: appDirectory, + env: { + ...process.env, + VITE_TANSTACK_DB_TAURI_E2E_RUN_ID: runId, + VITE_TANSTACK_DB_TAURI_E2E_REPORT_URL: reportUrl, + }, + stdio: [`ignore`, `pipe`, `pipe`], + detached: true, + }) + + child.stdout.on(`data`, (chunk) => { + stdoutCollector.append(chunk) + }) + child.stderr.on(`data`, (chunk) => { + stderrCollector.append(chunk) + }) + + const startedAt = Date.now() + + try { + while (Date.now() - startedAt < timeoutMs) { + if (child.exitCode !== null) { + throw new Error( + `Tauri e2e process exited before producing results.\nLatest message: ${JSON.stringify(latestMessage)}\nSTDOUT:\n${stdoutCollector.getOutput()}\nSTDERR:\n${stderrCollector.getOutput()}`, + ) + } + + if (isTauriE2ERunResult(latestMessage)) { + return latestMessage + } + + await delay(1_000) + } + + throw new Error( + `Timed out waiting for Tauri e2e results.\nLatest message: ${JSON.stringify(latestMessage)}\nSTDOUT:\n${stdoutCollector.getOutput()}\nSTDERR:\n${stderrCollector.getOutput()}`, + ) + } finally { + try { + if (typeof child.pid === `number`) { + process.kill(-child.pid, `SIGTERM`) + } + } catch { + try { + child.kill(`SIGTERM`) + } catch {} + } + + await delay(1_000).catch(() => {}) + await new Promise((resolve) => { + reportServer.close(() => resolve()) + }) + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/shared/register-persisted-collection-conformance-suite.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/shared/register-persisted-collection-conformance-suite.ts new file mode 100644 index 000000000..413485c4e --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/shared/register-persisted-collection-conformance-suite.ts @@ -0,0 +1,45 @@ +import { afterAll, afterEach, beforeAll } from 'vitest' +import { runPersistedCollectionConformanceSuite } from '../../../db-sqlite-persisted-collection-core/tests/contracts/persisted-collection-conformance-contract' +import type { TauriPersistedCollectionHarnessConfig } from './tauri-persisted-collection-harness' + +type RegisteredHarness = { + config: TauriPersistedCollectionHarnessConfig + teardown: () => Promise +} + +export function registerPersistedCollectionConformanceSuite(options: { + suiteName: string + createHarness: () => Promise +}): void { + const { suiteName, createHarness } = options + let config: TauriPersistedCollectionHarnessConfig | undefined + let teardown = () => Promise.resolve() + + beforeAll(async () => { + const harness = await createHarness() + config = harness.config + teardown = harness.teardown + }) + + afterEach(async () => { + if (config?.afterEach) { + await config.afterEach() + } + }) + + afterAll(async () => { + if (config) { + await teardown() + } + }) + + const getConfig = (): Promise => { + if (!config) { + throw new Error(`${suiteName} config is not initialized`) + } + + return Promise.resolve(config) + } + + runPersistedCollectionConformanceSuite(suiteName, getConfig) +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/shared/tauri-persisted-collection-harness.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/shared/tauri-persisted-collection-harness.ts new file mode 100644 index 000000000..3a46f5168 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/shared/tauri-persisted-collection-harness.ts @@ -0,0 +1,274 @@ +import { createCollection } from '@tanstack/db' +import { persistedCollectionOptions } from '../../src' +import { generateSeedData } from '../../../db-collection-e2e/src/fixtures/seed-data' +import type { Collection } from '@tanstack/db' +import type { + Comment, + E2ETestConfig, + Post, + User, +} from '../../../db-collection-e2e/src/types' +import type { PersistedCollectionPersistence } from '@tanstack/db-sqlite-persisted-collection-core' + +type PersistableRow = { + id: string +} + +type PersistedCollectionHarness = { + collection: Collection + seedPersisted: (rows: Array) => Promise +} + +type PersistedTransactionHandle = { + isPersisted: { + promise: Promise + } +} + +type PersistenceFactory = ( + database: TDatabase, +) => PersistedCollectionPersistence + +type DatabaseLike = { + close?: () => Promise | unknown +} + +export type TauriPersistedCollectionHarnessConfig = E2ETestConfig + +function createPersistedCollection( + database: TDatabase, + id: string, + syncMode: `eager` | `on-demand`, + createPersistence: PersistenceFactory, +): PersistedCollectionHarness { + const persistence = createPersistence(database) + let seedTxSequence = 0 + + const seedPersisted = async (rows: Array): Promise => { + if (rows.length === 0) { + return + } + + seedTxSequence++ + await persistence.adapter.applyCommittedTx(id, { + txId: `seed-${id}-${seedTxSequence}`, + term: 1, + seq: seedTxSequence, + rowVersion: seedTxSequence, + mutations: rows.map((row) => ({ + type: `insert` as const, + key: row.id, + value: row, + })), + }) + } + + const collection = createCollection( + persistedCollectionOptions({ + id, + syncMode, + getKey: (item) => item.id, + persistence, + }), + ) + + return { + collection, + seedPersisted, + } +} + +async function waitForPersisted( + transaction: PersistedTransactionHandle, +): Promise { + await transaction.isPersisted.promise +} + +async function seedCollection( + collection: Collection, + rows: Array, +): Promise { + const tx = collection.insert(rows) + await waitForPersisted(tx) +} + +async function insertRowIntoCollections( + collections: ReadonlyArray>, + row: T, +): Promise { + for (const collection of collections) { + const tx = collection.insert(row) + await waitForPersisted(tx) + } +} + +async function updateRowAcrossCollections( + collections: ReadonlyArray>, + id: string, + updates: Partial, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + + const tx = collection.update(id, (draft) => { + Object.assign(draft, updates) + }) + await waitForPersisted(tx) + } +} + +async function deleteRowAcrossCollections( + collections: ReadonlyArray>, + id: string, +): Promise { + for (const collection of collections) { + if (!collection.has(id)) { + continue + } + + const tx = collection.delete(id) + await waitForPersisted(tx) + } +} + +export async function createTauriPersistedCollectionHarnessConfig< + TDatabase extends DatabaseLike, +>(options: { + database: TDatabase + createPersistence: PersistenceFactory + suiteId?: string + cleanup?: () => Promise +}): Promise<{ + config: TauriPersistedCollectionHarnessConfig + teardown: () => Promise +}> { + const { + database, + createPersistence, + suiteId = Date.now().toString(36), + cleanup = () => Promise.resolve(), + } = options + const seedData = generateSeedData() + + const eagerUsers = createPersistedCollection( + database, + `tauri-persisted-users-eager-${suiteId}`, + `eager`, + createPersistence, + ) + const eagerPosts = createPersistedCollection( + database, + `tauri-persisted-posts-eager-${suiteId}`, + `eager`, + createPersistence, + ) + const eagerComments = createPersistedCollection( + database, + `tauri-persisted-comments-eager-${suiteId}`, + `eager`, + createPersistence, + ) + + const onDemandUsers = createPersistedCollection( + database, + `tauri-persisted-users-ondemand-${suiteId}`, + `on-demand`, + createPersistence, + ) + const onDemandPosts = createPersistedCollection( + database, + `tauri-persisted-posts-ondemand-${suiteId}`, + `on-demand`, + createPersistence, + ) + const onDemandComments = createPersistedCollection( + database, + `tauri-persisted-comments-ondemand-${suiteId}`, + `on-demand`, + createPersistence, + ) + + await Promise.all([ + eagerUsers.collection.preload(), + eagerPosts.collection.preload(), + eagerComments.collection.preload(), + ]) + + await seedCollection(eagerUsers.collection, seedData.users) + await seedCollection(eagerPosts.collection, seedData.posts) + await seedCollection(eagerComments.collection, seedData.comments) + await onDemandUsers.seedPersisted(seedData.users) + await onDemandPosts.seedPersisted(seedData.posts) + await onDemandComments.seedPersisted(seedData.comments) + + const teardown = async (): Promise => { + await Promise.all([ + eagerUsers.collection.cleanup(), + eagerPosts.collection.cleanup(), + eagerComments.collection.cleanup(), + onDemandUsers.collection.cleanup(), + onDemandPosts.collection.cleanup(), + onDemandComments.collection.cleanup(), + ]) + await Promise.resolve(database.close?.()) + await cleanup() + } + + const config: TauriPersistedCollectionHarnessConfig = { + collections: { + eager: { + users: eagerUsers.collection, + posts: eagerPosts.collection, + comments: eagerComments.collection, + }, + onDemand: { + users: onDemandUsers.collection, + posts: onDemandPosts.collection, + comments: onDemandComments.collection, + }, + }, + mutations: { + insertUser: async (user: User) => + insertRowIntoCollections( + [eagerUsers.collection, onDemandUsers.collection], + user, + ), + updateUser: async (id: string, updates: Partial) => + updateRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + updates, + ), + deleteUser: async (id: string) => + deleteRowAcrossCollections( + [eagerUsers.collection, onDemandUsers.collection], + id, + ), + insertPost: async (post: Post) => + insertRowIntoCollections( + [eagerPosts.collection, onDemandPosts.collection], + post, + ), + }, + setup: async () => {}, + afterEach: async () => { + await Promise.all([ + onDemandUsers.collection.cleanup(), + onDemandPosts.collection.cleanup(), + onDemandComments.collection.cleanup(), + ]) + + onDemandUsers.collection.startSyncImmediate() + onDemandPosts.collection.startSyncImmediate() + onDemandComments.collection.startSyncImmediate() + }, + teardown, + } + + return { + config, + teardown, + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/e2e/tauri-persisted-collection.e2e.test.ts b/packages/db-tauri-sqlite-persisted-collection/e2e/tauri-persisted-collection.e2e.test.ts new file mode 100644 index 000000000..ab58c2034 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/e2e/tauri-persisted-collection.e2e.test.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import { runTauriPersistedCollectionE2E } from './run-tauri-e2e' + +it(`runs the persisted collection conformance suite in a real Tauri runtime`, async () => { + const result = await runTauriPersistedCollectionE2E() + + expect(result.status).toBe(`passed`) +}) diff --git a/packages/db-tauri-sqlite-persisted-collection/package.json b/packages/db-tauri-sqlite-persisted-collection/package.json new file mode 100644 index 000000000..6ccbe5f65 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/package.json @@ -0,0 +1,71 @@ +{ + "name": "@tanstack/db-tauri-sqlite-persisted-collection", + "version": "0.1.0", + "description": "Tauri SQLite persisted collection adapter for TanStack DB", + "author": "TanStack Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/db-tauri-sqlite-persisted-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "sqlite", + "tauri", + "persistence", + "typescript" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest --run", + "test:e2e": "pnpm --filter @tanstack/db-ivm build && pnpm --filter @tanstack/db build && pnpm --filter @tanstack/db-sqlite-persisted-collection-core build && pnpm --filter @tanstack/db-tauri-sqlite-persisted-collection build && vitest --config vitest.e2e.config.ts --run" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./tauri": { + "import": { + "types": "./dist/esm/tauri.d.ts", + "default": "./dist/esm/tauri.js" + }, + "require": { + "types": "./dist/cjs/tauri.d.cts", + "default": "./dist/cjs/tauri.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/db-sqlite-persisted-collection-core": "workspace:*" + }, + "peerDependencies": { + "@tauri-apps/plugin-sql": "^2.3.2", + "typescript": ">=4.7" + }, + "devDependencies": { + "@tauri-apps/plugin-sql": "^2.3.2", + "@types/better-sqlite3": "^7.6.13", + "@vitest/coverage-istanbul": "^3.2.4", + "better-sqlite3": "^12.6.2" + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/src/index.ts b/packages/db-tauri-sqlite-persisted-collection/src/index.ts new file mode 100644 index 000000000..5e2642ca4 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/src/index.ts @@ -0,0 +1,11 @@ +export { createTauriSQLitePersistence } from './tauri' +export type { + TauriSQLiteDatabaseLike, + TauriSQLitePersistenceOptions, + TauriSQLiteSchemaMismatchPolicy, +} from './tauri' +export { persistedCollectionOptions } from '@tanstack/db-sqlite-persisted-collection-core' +export type { + PersistedCollectionCoordinator, + PersistedCollectionPersistence, +} from '@tanstack/db-sqlite-persisted-collection-core' diff --git a/packages/db-tauri-sqlite-persisted-collection/src/tauri-persistence.ts b/packages/db-tauri-sqlite-persisted-collection/src/tauri-persistence.ts new file mode 100644 index 000000000..2d7f011c3 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/src/tauri-persistence.ts @@ -0,0 +1,173 @@ +import { + SingleProcessCoordinator, + createSQLiteCorePersistenceAdapter, +} from '@tanstack/db-sqlite-persisted-collection-core' +import { TauriSQLiteDriver } from './tauri-sql-driver' +import type { + PersistedCollectionCoordinator, + PersistedCollectionMode, + PersistedCollectionPersistence, + SQLiteCoreAdapterOptions, + SQLiteDriver, +} from '@tanstack/db-sqlite-persisted-collection-core' +import type { TauriSQLiteDatabaseLike } from './tauri-sql-driver' + +export type { TauriSQLiteDatabaseLike } from './tauri-sql-driver' + +type TauriSQLiteCoreSchemaMismatchPolicy = + | `sync-present-reset` + | `sync-absent-error` + | `reset` + +export type TauriSQLiteSchemaMismatchPolicy = + | TauriSQLiteCoreSchemaMismatchPolicy + | `throw` + +type TauriSQLitePersistenceBaseOptions = Omit< + SQLiteCoreAdapterOptions, + `driver` | `schemaVersion` | `schemaMismatchPolicy` +> & { + database: TauriSQLiteDatabaseLike + coordinator?: PersistedCollectionCoordinator + schemaMismatchPolicy?: TauriSQLiteSchemaMismatchPolicy +} + +export type TauriSQLitePersistenceOptions = TauriSQLitePersistenceBaseOptions + +const tauriDriverCache = new WeakMap< + TauriSQLiteDatabaseLike, + TauriSQLiteDriver +>() + +function normalizeSchemaMismatchPolicy( + policy: TauriSQLiteSchemaMismatchPolicy, +): TauriSQLiteCoreSchemaMismatchPolicy { + if (policy === `throw`) { + return `sync-absent-error` + } + + return policy +} + +function resolveSchemaMismatchPolicy( + explicitPolicy: TauriSQLiteSchemaMismatchPolicy | undefined, + mode: PersistedCollectionMode, +): TauriSQLiteCoreSchemaMismatchPolicy { + if (explicitPolicy) { + return normalizeSchemaMismatchPolicy(explicitPolicy) + } + + return mode === `sync-present` ? `sync-present-reset` : `sync-absent-error` +} + +function createAdapterCacheKey( + schemaMismatchPolicy: TauriSQLiteCoreSchemaMismatchPolicy, + schemaVersion: number | undefined, +): string { + const schemaVersionKey = + schemaVersion === undefined ? `schema:default` : `schema:${schemaVersion}` + return `${schemaMismatchPolicy}|${schemaVersionKey}` +} + +function createInternalSQLiteDriver( + options: TauriSQLitePersistenceOptions, +): SQLiteDriver { + const cachedDriver = tauriDriverCache.get(options.database) + if (cachedDriver) { + return cachedDriver + } + + const driver = new TauriSQLiteDriver({ + database: options.database, + }) + tauriDriverCache.set(options.database, driver) + return driver +} + +function resolveAdapterBaseOptions( + options: TauriSQLitePersistenceOptions, +): Omit< + SQLiteCoreAdapterOptions, + `driver` | `schemaVersion` | `schemaMismatchPolicy` +> { + return { + appliedTxPruneMaxRows: options.appliedTxPruneMaxRows, + appliedTxPruneMaxAgeSeconds: options.appliedTxPruneMaxAgeSeconds, + pullSinceReloadThreshold: options.pullSinceReloadThreshold, + } +} + +export function createTauriSQLitePersistence< + T extends object, + TKey extends string | number = string | number, +>( + options: TauriSQLitePersistenceOptions, +): PersistedCollectionPersistence { + const { coordinator, schemaMismatchPolicy } = options + const driver = createInternalSQLiteDriver(options) + const adapterBaseOptions = resolveAdapterBaseOptions(options) + const resolvedCoordinator = coordinator ?? new SingleProcessCoordinator() + const adapterCache = new Map< + string, + ReturnType< + typeof createSQLiteCorePersistenceAdapter< + Record, + string | number + > + > + >() + + const getAdapterForCollection = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ) => { + const resolvedSchemaMismatchPolicy = resolveSchemaMismatchPolicy( + schemaMismatchPolicy, + mode, + ) + const cacheKey = createAdapterCacheKey( + resolvedSchemaMismatchPolicy, + schemaVersion, + ) + const cachedAdapter = adapterCache.get(cacheKey) + if (cachedAdapter) { + return cachedAdapter + } + + const adapter = createSQLiteCorePersistenceAdapter< + Record, + string | number + >({ + ...adapterBaseOptions, + driver, + schemaMismatchPolicy: resolvedSchemaMismatchPolicy, + ...(schemaVersion === undefined ? {} : { schemaVersion }), + }) + adapterCache.set(cacheKey, adapter) + return adapter + } + + const createCollectionPersistence = ( + mode: PersistedCollectionMode, + schemaVersion: number | undefined, + ): PersistedCollectionPersistence => ({ + adapter: getAdapterForCollection( + mode, + schemaVersion, + ) as unknown as PersistedCollectionPersistence[`adapter`], + coordinator: resolvedCoordinator, + }) + + const defaultPersistence = createCollectionPersistence( + `sync-absent`, + undefined, + ) + + return { + ...defaultPersistence, + resolvePersistenceForCollection: ({ mode, schemaVersion }) => + createCollectionPersistence(mode, schemaVersion), + resolvePersistenceForMode: (mode) => + createCollectionPersistence(mode, undefined), + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/src/tauri-sql-driver.ts b/packages/db-tauri-sqlite-persisted-collection/src/tauri-sql-driver.ts new file mode 100644 index 000000000..6738680d9 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/src/tauri-sql-driver.ts @@ -0,0 +1,290 @@ +import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persisted-collection-core' +import type { SQLiteDriver } from '@tanstack/db-sqlite-persisted-collection-core' +import type Database from '@tauri-apps/plugin-sql' + +export type TauriSQLiteDatabaseLike = Pick< + Database, + `execute` | `select` | `close` | `path` +> + +export type TauriSQLiteDriverOptions = { + database: TauriSQLiteDatabaseLike +} + +function assertTransactionCallbackHasDriverArg( + fn: (transactionDriver: SQLiteDriver) => Promise, +): void { + if (fn.length > 0) { + return + } + + throw new InvalidPersistedCollectionConfigError( + `SQLiteDriver.transaction callback must accept the transaction driver argument`, + ) +} + +function isTauriSQLiteDatabaseLike( + value: unknown, +): value is TauriSQLiteDatabaseLike { + const candidate = value as Partial + return ( + typeof value === `object` && + value !== null && + typeof candidate.path === `string` && + typeof candidate.execute === `function` && + typeof candidate.select === `function` + ) +} + +function normalizeQueryRows( + rows: unknown, + sql: string, +): ReadonlyArray { + if (Array.isArray(rows)) { + return rows as ReadonlyArray + } + + throw new InvalidPersistedCollectionConfigError( + `Unsupported Tauri SQL query result shape for SQL "${sql}"`, + ) +} + +function convertSqlitePlaceholdersToTauri(sql: string): string { + let result = `` + let parameterIndex = 1 + let inSingleQuote = false + let inDoubleQuote = false + let inLineComment = false + let inBlockComment = false + + for (let index = 0; index < sql.length; index++) { + const currentChar = sql[index] + const nextChar = sql[index + 1] + + if (inLineComment) { + result += currentChar + if (currentChar === `\n`) { + inLineComment = false + } + continue + } + + if (inBlockComment) { + result += currentChar + if (currentChar === `*` && nextChar === `/`) { + result += `/` + index++ + inBlockComment = false + } + continue + } + + if (!inSingleQuote && !inDoubleQuote) { + if (currentChar === `-` && nextChar === `-`) { + result += `--` + index++ + inLineComment = true + continue + } + + if (currentChar === `/` && nextChar === `*`) { + result += `/*` + index++ + inBlockComment = true + continue + } + } + + if (currentChar === `'` && !inDoubleQuote) { + result += currentChar + if (inSingleQuote && nextChar === `'`) { + result += `'` + index++ + continue + } + inSingleQuote = !inSingleQuote + continue + } + + if (currentChar === `"` && !inSingleQuote) { + result += currentChar + if (inDoubleQuote && nextChar === `"`) { + result += `"` + index++ + continue + } + inDoubleQuote = !inDoubleQuote + continue + } + + if (currentChar === `?` && !inSingleQuote && !inDoubleQuote) { + result += `$${String(parameterIndex)}` + parameterIndex++ + continue + } + + result += currentChar + } + + return result +} + +export class TauriSQLiteDriver implements SQLiteDriver { + private readonly database: TauriSQLiteDatabaseLike + private queue: Promise = Promise.resolve() + private nextSavepointId = 1 + + constructor(options: TauriSQLiteDriverOptions) { + if (!isTauriSQLiteDatabaseLike(options.database)) { + throw new InvalidPersistedCollectionConfigError( + `Tauri SQLite database object must provide execute/select methods`, + ) + } + + this.database = options.database + } + + async exec(sql: string): Promise { + await this.enqueue(async () => { + await this.executeStatement(sql) + }) + } + + async query( + sql: string, + params: ReadonlyArray = [], + ): Promise> { + return this.enqueue(async () => { + const rows = await this.database.select>( + convertSqlitePlaceholdersToTauri(sql), + params.length > 0 ? [...params] : undefined, + ) + return normalizeQueryRows(rows, sql) + }) + } + + async run(sql: string, params: ReadonlyArray = []): Promise { + await this.enqueue(async () => { + await this.executeStatement(sql, params) + }) + } + + async transaction( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + assertTransactionCallbackHasDriverArg(fn) + return this.transactionWithDriver(fn) + } + + async transactionWithDriver( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + return this.enqueue(async () => { + await this.executeStatement(`BEGIN IMMEDIATE`) + const transactionDriver = this.createTransactionDriver() + + try { + const result = await fn(transactionDriver) + await this.executeStatement(`COMMIT`) + return result + } catch (error) { + try { + await this.executeStatement(`ROLLBACK`) + } catch { + // Keep the original transaction error as the primary failure. + } + throw error + } + }) + } + + async close(): Promise { + if (typeof this.database.close !== `function`) { + return + } + + await Promise.resolve(this.database.close(this.database.path)) + } + + getDatabase(): TauriSQLiteDatabaseLike { + return this.database + } + + private async executeStatement( + sql: string, + params: ReadonlyArray = [], + ): Promise { + await this.database.execute( + convertSqlitePlaceholdersToTauri(sql), + params.length > 0 ? [...params] : undefined, + ) + } + + private enqueue( + operation: () => Promise, + ): Promise { + const queuedOperation = this.queue.then(operation, operation) + this.queue = queuedOperation.then( + () => undefined, + () => undefined, + ) + return queuedOperation + } + + private createTransactionDriver(): SQLiteDriver { + const transactionDriver: SQLiteDriver = { + exec: async (sql) => { + await this.executeStatement(sql) + }, + query: async ( + sql: string, + params: ReadonlyArray = [], + ): Promise> => { + const rows = await this.database.select>( + convertSqlitePlaceholdersToTauri(sql), + params.length > 0 ? [...params] : undefined, + ) + return normalizeQueryRows(rows, sql) + }, + run: async (sql, params = []) => { + await this.executeStatement(sql, params) + }, + transaction: async ( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise => { + assertTransactionCallbackHasDriverArg(fn) + return this.runNestedTransaction(transactionDriver, fn) + }, + transactionWithDriver: async ( + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise => this.runNestedTransaction(transactionDriver, fn), + } + + return transactionDriver + } + + private async runNestedTransaction( + transactionDriver: SQLiteDriver, + fn: (transactionDriver: SQLiteDriver) => Promise, + ): Promise { + const savepointName = `tsdb_sp_${this.nextSavepointId}` + this.nextSavepointId++ + await this.executeStatement(`SAVEPOINT ${savepointName}`) + + try { + const result = await fn(transactionDriver) + await this.executeStatement(`RELEASE SAVEPOINT ${savepointName}`) + return result + } catch (error) { + await this.executeStatement(`ROLLBACK TO SAVEPOINT ${savepointName}`) + await this.executeStatement(`RELEASE SAVEPOINT ${savepointName}`) + throw error + } + } +} + +export function createTauriSQLiteDriver( + options: TauriSQLiteDriverOptions, +): TauriSQLiteDriver { + return new TauriSQLiteDriver(options) +} diff --git a/packages/db-tauri-sqlite-persisted-collection/src/tauri.ts b/packages/db-tauri-sqlite-persisted-collection/src/tauri.ts new file mode 100644 index 000000000..69e98095c --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/src/tauri.ts @@ -0,0 +1,20 @@ +import { createTauriSQLitePersistence as createPersistence } from './tauri-persistence' +import type { + TauriSQLitePersistenceOptions as TauriSQLitePersistenceOptionsBase, + TauriSQLiteSchemaMismatchPolicy as TauriSQLiteSchemaMismatchPolicyBase, +} from './tauri-persistence' +import type { PersistedCollectionPersistence } from '@tanstack/db-sqlite-persisted-collection-core' + +export type TauriSQLitePersistenceOptions = TauriSQLitePersistenceOptionsBase +export type TauriSQLiteSchemaMismatchPolicy = + TauriSQLiteSchemaMismatchPolicyBase +export type { TauriSQLiteDatabaseLike } from './tauri-persistence' + +export function createTauriSQLitePersistence< + T extends object, + TKey extends string | number = string | number, +>( + options: TauriSQLitePersistenceOptions, +): PersistedCollectionPersistence { + return createPersistence(options) +} diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/helpers/tauri-sql-test-db.ts b/packages/db-tauri-sqlite-persisted-collection/tests/helpers/tauri-sql-test-db.ts new file mode 100644 index 000000000..d696696b9 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/helpers/tauri-sql-test-db.ts @@ -0,0 +1,46 @@ +import BetterSqlite3 from 'better-sqlite3' +import type { TauriSQLiteDatabaseLike } from '../../src/tauri-sql-driver' + +function convertTauriPlaceholdersToSqlite(sql: string): string { + return sql.replace(/\$\d+/g, `?`) +} + +export function createTauriSQLiteTestDatabase(options: { + filename: string +}): TauriSQLiteDatabaseLike & { + close: (db?: string) => Promise +} { + const database = new BetterSqlite3(options.filename) + + return { + path: options.filename, + execute: async (sql, bindValues: Array = []) => { + const normalizedSql = convertTauriPlaceholdersToSqlite(sql) + const statement = database.prepare(normalizedSql) + const result = + bindValues.length > 0 ? statement.run(...bindValues) : statement.run() + + return { + rowsAffected: result.changes, + lastInsertId: + typeof result.lastInsertRowid === `bigint` + ? Number(result.lastInsertRowid) + : result.lastInsertRowid, + } + }, + select: async ( + sql: string, + bindValues: Array = [], + ): Promise => { + const normalizedSql = convertTauriPlaceholdersToSqlite(sql) + const statement = database.prepare(normalizedSql) + const rows = + bindValues.length > 0 ? statement.all(...bindValues) : statement.all() + return rows as TRow + }, + close: async () => { + database.close() + return true + }, + } +} diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/tauri-persistence.test.ts b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-persistence.test.ts new file mode 100644 index 000000000..c3486f873 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-persistence.test.ts @@ -0,0 +1,176 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { createCollection } from '@tanstack/db' +import { + createTauriSQLitePersistence as createIndexPersistence, + persistedCollectionOptions, +} from '../src' +import { createTauriSQLitePersistence as createTauriPersistence } from '../src/tauri' +import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' + +type Todo = { + id: string + title: string + score: number +} + +const activeCleanupFns: Array<() => void | Promise> = [] + +afterEach(async () => { + while (activeCleanupFns.length > 0) { + const cleanupFn = activeCleanupFns.pop() + await Promise.resolve(cleanupFn?.()) + } +}) + +function createTempSqlitePath(): string { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-tauri-persistence-test-`), + ) + const dbPath = join(tempDirectory, `state.sqlite`) + activeCleanupFns.push(() => { + rmSync(tempDirectory, { recursive: true, force: true }) + }) + return dbPath +} + +it(`persists data across app restart (close and reopen)`, async () => { + const dbPath = createTempSqlitePath() + const collectionId = `todos-restart` + + const firstDatabase = createTauriSQLiteTestDatabase({ filename: dbPath }) + const firstPersistence = createTauriPersistence({ + database: firstDatabase, + }) + const firstAdapter = firstPersistence.adapter + + await firstAdapter.applyCommittedTx(collectionId, { + txId: `tx-restart-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `Survives restart`, + score: 10, + }, + }, + ], + }) + await Promise.resolve(firstDatabase.close()) + + const secondDatabase = createTauriSQLiteTestDatabase({ filename: dbPath }) + activeCleanupFns.push(async () => { + await Promise.resolve(secondDatabase.close()) + }) + const secondPersistence = createTauriPersistence({ + database: secondDatabase, + }) + const secondAdapter = secondPersistence.adapter + + const rows = await secondAdapter.loadSubset(collectionId, {}) + expect(rows).toEqual([ + { + key: `1`, + value: { + id: `1`, + title: `Survives restart`, + score: 10, + }, + }, + ]) +}) + +it(`shares the same runtime behavior through index and tauri entrypoints`, async () => { + const dbPath = createTempSqlitePath() + const collectionId = `todos-entrypoints` + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + activeCleanupFns.push(async () => { + await Promise.resolve(database.close()) + }) + + const indexPersistence = createIndexPersistence({ database }) + const tauriPersistence = createTauriPersistence({ database }) + + await indexPersistence.adapter.applyCommittedTx(collectionId, { + txId: `tx-entrypoint-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `Entry point parity`, + score: 1, + }, + }, + ], + }) + + const rows = await tauriPersistence.adapter.loadSubset(collectionId, {}) + expect(rows[0]?.value.title).toBe(`Entry point parity`) +}) + +it(`resumes persisted sync after cleanup and restart`, async () => { + const dbPath = createTempSqlitePath() + const collectionId = `todos-lifecycle` + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + activeCleanupFns.push(async () => { + await Promise.resolve(database.close()) + }) + + const persistence = createTauriPersistence({ + database, + }) + const collection = createCollection( + persistedCollectionOptions({ + id: collectionId, + getKey: (todo) => todo.id, + persistence, + syncMode: `eager`, + }), + ) + activeCleanupFns.push(() => collection.cleanup()) + + await collection.stateWhenReady() + const initialInsert = collection.insert({ + id: `1`, + title: `Before cleanup`, + score: 1, + }) + await initialInsert.isPersisted.promise + expect(collection.get(`1`)?.title).toBe(`Before cleanup`) + + await collection.cleanup() + collection.startSyncImmediate() + await collection.stateWhenReady() + + const resumedInsert = collection.insert({ + id: `2`, + title: `After restart`, + score: 2, + }) + await resumedInsert.isPersisted.promise + expect(collection.get(`2`)?.title).toBe(`After restart`) + + const persistedRows = await persistence.adapter.loadSubset(collectionId, {}) + expect(persistedRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: `1`, + }), + expect.objectContaining({ + key: `2`, + }), + ]), + ) +}) diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/tauri-runtime-persistence-contract.test.ts b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-runtime-persistence-contract.test.ts new file mode 100644 index 000000000..55c6096a3 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-runtime-persistence-contract.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + createTauriSQLitePersistence, + persistedCollectionOptions, +} from '../src' +import { TauriSQLiteDriver } from '../src/tauri-sql-driver' +import { runRuntimePersistenceContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-persistence-contract' +import { SingleProcessCoordinator } from '../../db-sqlite-persisted-collection-core/src' +import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' +import type { + RuntimePersistenceContractTodo, + RuntimePersistenceDatabaseHarness, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/runtime-persistence-contract' + +function createRuntimeDatabaseHarness(): RuntimePersistenceDatabaseHarness { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-tauri-persistence-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const drivers = new Set() + const databases = new Set>() + + return { + createDriver: () => { + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + const driver = new TauriSQLiteDriver({ database }) + databases.add(database) + drivers.add(driver) + return driver + }, + cleanup: async () => { + for (const database of databases) { + try { + await Promise.resolve(database.close()) + } catch { + // ignore cleanup errors from already-closed handles + } + } + databases.clear() + drivers.clear() + rmSync(tempDirectory, { recursive: true, force: true }) + }, + } +} + +runRuntimePersistenceContractSuite(`tauri runtime persistence helpers`, { + createDatabaseHarness: createRuntimeDatabaseHarness, + createAdapter: (driver) => + createTauriSQLitePersistence({ + database: (driver as TauriSQLiteDriver).getDatabase(), + }).adapter, + createPersistence: (driver, coordinator) => + createTauriSQLitePersistence({ + database: (driver as TauriSQLiteDriver).getDatabase(), + coordinator, + }), + createCoordinator: () => new SingleProcessCoordinator(), +}) + +describe(`tauri runtime persistence helpers`, () => { + it(`caches adapters by schema policy and schema version`, () => { + const runtimeHarness = createRuntimeDatabaseHarness() + const driver = runtimeHarness.createDriver() + try { + const persistence = createTauriSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database: (driver as TauriSQLiteDriver).getDatabase(), + }) + const resolvePersistenceForCollection = + persistence.resolvePersistenceForCollection + expect(resolvePersistenceForCollection).toBeTypeOf(`function`) + if (resolvePersistenceForCollection === undefined) { + throw new Error( + `resolvePersistenceForCollection must be available for runtime helpers`, + ) + } + + const syncAbsentDefault = resolvePersistenceForCollection({ + collectionId: `todos-a`, + mode: `sync-absent`, + }) + const syncAbsentDefaultAgain = resolvePersistenceForCollection({ + collectionId: `todos-b`, + mode: `sync-absent`, + }) + const syncPresentDefault = resolvePersistenceForCollection({ + collectionId: `todos-c`, + mode: `sync-present`, + }) + const schemaVersionOne = resolvePersistenceForCollection({ + collectionId: `todos-d`, + mode: `sync-absent`, + schemaVersion: 1, + }) + const schemaVersionOneAgain = resolvePersistenceForCollection({ + collectionId: `todos-e`, + mode: `sync-absent`, + schemaVersion: 1, + }) + const schemaVersionTwo = resolvePersistenceForCollection({ + collectionId: `todos-f`, + mode: `sync-absent`, + schemaVersion: 2, + }) + + expect(syncAbsentDefault.adapter).toBe(syncAbsentDefaultAgain.adapter) + expect(syncAbsentDefault.adapter).not.toBe(syncPresentDefault.adapter) + expect(schemaVersionOne.adapter).toBe(schemaVersionOneAgain.adapter) + expect(schemaVersionOne.adapter).not.toBe(schemaVersionTwo.adapter) + } finally { + runtimeHarness.cleanup() + } + }) + + it(`defaults coordinator to SingleProcessCoordinator`, () => { + const runtimeHarness = createRuntimeDatabaseHarness() + const driver = runtimeHarness.createDriver() + try { + const persistence = createTauriSQLitePersistence({ + database: (driver as TauriSQLiteDriver).getDatabase(), + }) + expect(persistence.coordinator).toBeInstanceOf(SingleProcessCoordinator) + } finally { + runtimeHarness.cleanup() + } + }) + + it(`allows overriding the default coordinator`, () => { + const runtimeHarness = createRuntimeDatabaseHarness() + const driver = runtimeHarness.createDriver() + try { + const coordinator = new SingleProcessCoordinator() + const persistence = createTauriSQLitePersistence({ + database: (driver as TauriSQLiteDriver).getDatabase(), + coordinator, + }) + expect(persistence.coordinator).toBe(coordinator) + } finally { + runtimeHarness.cleanup() + } + }) + + it(`infers schema policy from sync mode`, async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-tauri-schema-infer-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const collectionId = `todos` + const firstDatabase = createTauriSQLiteTestDatabase({ filename: dbPath }) + + try { + const firstPersistence = createTauriSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database: firstDatabase, + }) + const firstCollectionOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 1, + getKey: (todo) => todo.id, + persistence: firstPersistence, + }) + + await firstCollectionOptions.persistence.adapter.applyCommittedTx( + collectionId, + { + txId: `tx-1`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `before mismatch`, + score: 1, + }, + }, + ], + }, + ) + } finally { + await Promise.resolve(firstDatabase.close()) + } + + const secondDatabase = createTauriSQLiteTestDatabase({ filename: dbPath }) + try { + const secondPersistence = createTauriSQLitePersistence< + RuntimePersistenceContractTodo, + string + >({ + database: secondDatabase, + }) + const syncAbsentOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 2, + getKey: (todo) => todo.id, + persistence: secondPersistence, + }) + await expect( + syncAbsentOptions.persistence.adapter.loadSubset(collectionId, {}), + ).rejects.toThrow(`Schema version mismatch`) + + const syncPresentOptions = persistedCollectionOptions< + RuntimePersistenceContractTodo, + string + >({ + id: collectionId, + schemaVersion: 2, + getKey: (todo) => todo.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + persistence: secondPersistence, + }) + const rows = await syncPresentOptions.persistence.adapter.loadSubset( + collectionId, + {}, + ) + expect(rows).toEqual([]) + } finally { + await Promise.resolve(secondDatabase.close()) + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver-contract.test.ts b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver-contract.test.ts new file mode 100644 index 000000000..343e4f1bf --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver-contract.test.ts @@ -0,0 +1,27 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runSQLiteDriverContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-driver-contract' +import { TauriSQLiteDriver } from '../src/tauri-sql-driver' +import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' +import type { SQLiteDriverContractHarness } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-driver-contract' + +function createDriverHarness(): SQLiteDriverContractHarness { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-tauri-sqlite-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + const driver = new TauriSQLiteDriver({ database }) + + return { + driver, + cleanup: async () => { + try { + database.close() + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }, + } +} + +runSQLiteDriverContractSuite(`tauri sqlite driver`, createDriverHarness) diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver.test.ts b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver.test.ts new file mode 100644 index 000000000..36f667042 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sql-driver.test.ts @@ -0,0 +1,156 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { InvalidPersistedCollectionConfigError } from '../../db-sqlite-persisted-collection-core/src' +import { TauriSQLiteDriver } from '../src/tauri-sql-driver' +import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' + +const activeCleanupFns: Array<() => void | Promise> = [] + +afterEach(async () => { + while (activeCleanupFns.length > 0) { + const cleanupFn = activeCleanupFns.pop() + await Promise.resolve(cleanupFn?.()) + } +}) + +function createTempSqlitePath(): string { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-tauri-test-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + activeCleanupFns.push(() => { + rmSync(tempDirectory, { recursive: true, force: true }) + }) + return dbPath +} + +it(`keeps literal question marks untouched while binding positional parameters`, async () => { + const dbPath = createTempSqlitePath() + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + activeCleanupFns.push(() => { + database.close() + }) + + const driver = new TauriSQLiteDriver({ database }) + await driver.exec( + `CREATE TABLE prompts (id TEXT PRIMARY KEY, title TEXT NOT NULL, notes TEXT NOT NULL)`, + ) + await driver.run( + `INSERT INTO prompts (id, title, notes) VALUES (?, ?, 'literal ? kept in SQL')`, + [`1`, `Why?`], + ) + + const rows = await driver.query<{ title: string; notes: string }>( + `SELECT title, notes + FROM prompts + WHERE title = ?`, + [`Why?`], + ) + + expect(rows).toEqual([ + { + title: `Why?`, + notes: `literal ? kept in SQL`, + }, + ]) +}) + +it(`does not convert question marks inside line comments`, async () => { + let capturedSql = `` + const driver = new TauriSQLiteDriver({ + database: { + path: `sqlite:test.db`, + execute: async (sql) => { + capturedSql = sql + return { rowsAffected: 0 } + }, + select: async () => [] as unknown as TRow, + close: async () => true, + }, + }) + + await driver.run( + `UPDATE prompts + SET notes = 'changed' + -- keep this literal ? untouched + WHERE id = ?`, + [`1`], + ) + + expect(capturedSql).toContain(`-- keep this literal ? untouched`) + expect(capturedSql).toContain(`WHERE id = $1`) +}) + +it(`does not convert question marks inside block comments`, async () => { + let capturedSql = `` + const driver = new TauriSQLiteDriver({ + database: { + path: `sqlite:test.db`, + execute: async () => ({ rowsAffected: 0 }), + select: async (sql: string) => { + capturedSql = sql + return [] as unknown as TRow + }, + close: async () => true, + }, + }) + + await driver.query( + `SELECT title + FROM prompts + /* keep this literal ? untouched */ + WHERE id = ?`, + [`1`], + ) + + expect(capturedSql).toContain(`/* keep this literal ? untouched */`) + expect(capturedSql).toContain(`WHERE id = $1`) +}) + +it(`keeps escaped single-quote literals unchanged while converting bindings`, async () => { + let capturedSql = `` + const driver = new TauriSQLiteDriver({ + database: { + path: `sqlite:test.db`, + execute: async (sql) => { + capturedSql = sql + return { rowsAffected: 0 } + }, + select: async () => [] as unknown as TRow, + close: async () => true, + }, + }) + + await driver.run( + `INSERT INTO prompts (id, title, notes) + VALUES (?, 'it''s still a literal ?', ?)`, + [`1`, `note`], + ) + + expect(capturedSql).toContain(`VALUES ($1, 'it''s still a literal ?', $2)`) +}) + +it(`closes the underlying database when close is available`, async () => { + let closeCount = 0 + const driver = new TauriSQLiteDriver({ + database: { + path: `sqlite:test.db`, + execute: async () => ({ rowsAffected: 0 }), + select: async () => [] as unknown as TRow, + close: async () => { + closeCount++ + return true + }, + }, + }) + + await driver.close() + + expect(closeCount).toBe(1) +}) + +it(`throws config error when execute/select methods are missing`, () => { + expect(() => new TauriSQLiteDriver({ database: {} as never })).toThrowError( + InvalidPersistedCollectionConfigError, + ) +}) diff --git a/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sqlite-core-adapter-contract.test.ts b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sqlite-core-adapter-contract.test.ts new file mode 100644 index 000000000..572d8db39 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tests/tauri-sqlite-core-adapter-contract.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runSQLiteCoreAdapterContractSuite } from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' +import { TauriSQLiteDriver } from '../src/tauri-sql-driver' +import { SQLiteCorePersistenceAdapter } from '../../db-sqlite-persisted-collection-core/src' +import { createTauriSQLiteTestDatabase } from './helpers/tauri-sql-test-db' +import type { + SQLiteCoreAdapterContractTodo, + SQLiteCoreAdapterHarnessFactory, +} from '../../db-sqlite-persisted-collection-core/tests/contracts/sqlite-core-adapter-contract' + +const createHarness: SQLiteCoreAdapterHarnessFactory = (options) => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-tauri-core-`)) + const dbPath = join(tempDirectory, `state.sqlite`) + const database = createTauriSQLiteTestDatabase({ filename: dbPath }) + const driver = new TauriSQLiteDriver({ database }) + + const adapter = new SQLiteCorePersistenceAdapter< + SQLiteCoreAdapterContractTodo, + string + >({ + driver, + ...options, + }) + + return { + adapter, + driver, + cleanup: async () => { + try { + database.close() + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }, + } +} + +runSQLiteCoreAdapterContractSuite( + `SQLiteCorePersistenceAdapter (tauri sqlite driver harness)`, + createHarness, +) diff --git a/packages/db-tauri-sqlite-persisted-collection/tsconfig.docs.json b/packages/db-tauri-sqlite-persisted-collection/tsconfig.docs.json new file mode 100644 index 000000000..5fddb4598 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tsconfig.docs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": ["src"] +} diff --git a/packages/db-tauri-sqlite-persisted-collection/tsconfig.json b/packages/db-tauri-sqlite-persisted-collection/tsconfig.json new file mode 100644 index 000000000..4c52e365d --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/db-sqlite-persisted-collection-core": [ + "../db-sqlite-persisted-collection-core/src" + ] + } + }, + "include": [ + "src", + "tests", + "e2e/**/*.ts", + "vite.config.ts", + "vitest.e2e.config.ts" + ], + "exclude": [ + "node_modules", + "dist", + "e2e/app/dist", + "e2e/app/src-tauri/target" + ] +} diff --git a/packages/db-tauri-sqlite-persisted-collection/vite.config.ts b/packages/db-tauri-sqlite-persisted-collection/vite.config.ts new file mode 100644 index 000000000..267364cb1 --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + exclude: [`e2e/**/*.e2e.test.ts`], + environment: `node`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [`./src/index.ts`, `./src/tauri.ts`], + srcDir: `./src`, + }), +) diff --git a/packages/db-tauri-sqlite-persisted-collection/vitest.e2e.config.ts b/packages/db-tauri-sqlite-persisted-collection/vitest.e2e.config.ts new file mode 100644 index 000000000..64236583c --- /dev/null +++ b/packages/db-tauri-sqlite-persisted-collection/vitest.e2e.config.ts @@ -0,0 +1,31 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +const packageDirectory = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + '@tanstack/db': resolve(packageDirectory, `../db/src`), + '@tanstack/db-ivm': resolve(packageDirectory, `../db-ivm/src`), + '@tanstack/db-sqlite-persisted-collection-core': resolve( + packageDirectory, + `../db-sqlite-persisted-collection-core/src`, + ), + }, + }, + test: { + include: [`e2e/**/*.e2e.test.ts`], + environment: `node`, + fileParallelism: false, + testTimeout: 120_000, + hookTimeout: 180_000, + typecheck: { + enabled: false, + }, + coverage: { + enabled: false, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7e98cd8..eca4266e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,7 +761,7 @@ importers: version: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@3.25.76) + version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.3.6) express: specifier: ^5.2.1 version: 5.2.1 @@ -1116,6 +1116,56 @@ importers: specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + packages/db-tauri-sqlite-persisted-collection: + dependencies: + '@tanstack/db-sqlite-persisted-collection-core': + specifier: workspace:* + version: link:../db-sqlite-persisted-collection-core + typescript: + specifier: '>=4.7' + version: 5.9.3 + devDependencies: + '@tauri-apps/plugin-sql': + specifier: ^2.3.2 + version: 2.3.2 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + better-sqlite3: + specifier: ^12.6.2 + version: 12.8.0 + + packages/db-tauri-sqlite-persisted-collection/e2e/app: + dependencies: + '@tanstack/db': + specifier: workspace:* + version: link:../../../db + '@tanstack/db-tauri-sqlite-persisted-collection': + specifier: workspace:* + version: link:../.. + '@tauri-apps/api': + specifier: ^2.10.1 + version: 2.10.1 + '@tauri-apps/plugin-sql': + specifier: ^2.3.2 + version: 2.3.2 + devDependencies: + '@tauri-apps/cli': + specifier: ^2.10.1 + version: 2.10.1 + '@types/node': + specifier: ^25.2.2 + version: 25.2.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + packages/electric-db-collection: dependencies: '@electric-sql/client': @@ -5294,6 +5344,83 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 + '@tauri-apps/api@2.10.1': + resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + + '@tauri-apps/cli-darwin-arm64@2.10.1': + resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.10.1': + resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-arm64-musl@2.10.1': + resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@tauri-apps/cli-linux-x64-gnu@2.10.1': + resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-linux-x64-musl@2.10.1': + resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.10.1': + resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.10.1': + resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-sql@2.3.2': + resolution: {integrity: sha512-4VDXhcKXVpyh5KKpnTGAn6q2DikPHH+TXGh9ZDQzULmG/JEz1RDvzQStgBJKddiukRbYEZ8CGIA2kskx+T+PpA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -16915,6 +17042,59 @@ snapshots: - supports-color - typescript + '@tauri-apps/api@2.10.1': {} + + '@tauri-apps/cli-darwin-arm64@2.10.1': + optional: true + + '@tauri-apps/cli-darwin-x64@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.10.1': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.10.1': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.10.1': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.10.1': + optional: true + + '@tauri-apps/cli@2.10.1': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.10.1 + '@tauri-apps/cli-darwin-x64': 2.10.1 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 + '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 + '@tauri-apps/cli-linux-arm64-musl': 2.10.1 + '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-gnu': 2.10.1 + '@tauri-apps/cli-linux-x64-musl': 2.10.1 + '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 + '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 + '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + + '@tauri-apps/plugin-sql@2.3.2': + dependencies: + '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -18806,11 +18986,6 @@ snapshots: postgres: 3.4.8 sql.js: 1.14.1 - drizzle-zod@0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@3.25.76): - dependencies: - drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) - zod: 3.25.76 - drizzle-zod@0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.3.6): dependencies: drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1)