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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,13 @@ Databases must be registered on the Rust side with a stable key before they can
- CI check that committed `api-iife.js` matches a fresh Rollup build.
- Scalar SQL functions written in Rust. Any query, from the frontend or from Rust, can call a registered function by name. Each database captures the registered set when it connects, so registration must precede the first `load`. See [Scalar Functions](README.md#scalar-functions) for the full contract. Aggregate functions, window functions, collations, virtual tables, and functions defined in JavaScript are out of scope.

#### Frontend API

- **`PathReplacedEvent`**: TypeScript type for the `sqlite:path-replaced` payload (`dbKey`, `dbPath`). The `Database` class subscribes on first construction and clears its cached path when the event fires. Callers can also `listen('sqlite:path-replaced', …)` from `@tauri-apps/api/event` if they need their own handling.

#### Rust API

- **`DatabaseRegistration::register_database`** on **`AppHandle`**, with **`IfExists`**: register a key after plugin setup. The call stores the path and returns `()`. It does not open the database. `IfExists::Error` returns `INVALID_CONFIG` when the key already exists. `IfExists::Replace` closes a loaded database and stores a different path. The key's current path, including after a failed migration, or a path already stored for another key, returns `INVALID_CONFIG` and leaves the registration in place. A same-path `Replace` does not run the migrator again. Close failure leaves the stored path unchanged and can leave the key unloaded, because the loaded instance and its subscriptions are cleared before the pool close finishes. `connect` reopens the stored path. A `Some` migrator is spawned the same way setup spawns one, and `connect` waits for it. `None` clears that key's migration state. Subscriptions and observers for the key are cleared when a path change closes the loaded database. A successful path change emits `sqlite:path-replaced`. A guest `Database` clears its cached path on that event. A write already in flight is not cancelled. After a later connect, that instance uses the new file. `getPath()` stays at the old path until `load()` runs again.
- **`Builder::register_function()`** and **`SetupRegistrar::register_function()`**: register a scalar SQL function. Use the registrar variant when the handler closes over the `app` instance. Use the builder variant otherwise. Both validate the name, the arity, the name-and-arity pair, and a collision with a SQLite built-in at the call. A bad registration produces an error there, not later when a connection opens.
- **`Builder::register_or_replace_function()`** and **`SetupRegistrar::register_or_replace_function()`**: the same registration, accepting a name and arity already registered. For a setup path that runs twice, where `register_function()` reports the repeat as an error.
- **`InvocationScope`**, the required **`ScalarFunction::invocation_scope`** field: `DirectOnly` confines a function to top-level SQL through `SQLITE_DIRECTONLY`, and `Schema` and `InnocuousSchema` let a view, a trigger, or an index call it. Pick `DirectOnly` unless a schema object must call the function: a handler reachable from the schema of a database this application did not write runs on any read through that object.
Expand Down
95 changes: 91 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ Or specify individual permissions:
}
```

The guest `Database` class subscribes to the `sqlite:path-replaced` app event after the
first `Database` is constructed. Ensure the webview can listen to Tauri events:
[`core:default`](https://v2.tauri.app/reference/acl/capability/) includes that capability;
when you list permissions explicitly, add
[`core:event:allow-listen`](https://v2.tauri.app/reference/acl/permission/) (or another
capability that grants event listen). Without it, path-replace handling does not run; the
failed `listen()` clears the one-shot slot so a later `Database` construction can retry.

## Compatibility / upgrading host app to Tauri 2.12

This plugin is developed and tested against **Tauri 2.12.0**.
Expand Down Expand Up @@ -183,10 +191,11 @@ in-memory URI. The frontend and Rust callers open databases by **key**, not by p
* In-memory URIs (`:memory:`, `file::memory:*`, and `file:` URIs with an exact
`mode=memory` query parameter) are accepted as-is
* Each registration **key** must map to a **distinct** database path; two keys for the
same path fail with `INVALID_CONFIG` when calling `build()` or during plugin setup
after `on_setup` merges registrations
same path fail with `INVALID_CONFIG` when calling `build()`, during plugin setup
after `on_setup` merges registrations, and on a later
`AppHandle::register_database`

Invalid registration paths fail at startup with `INVALID_PATH` or `PATH_TRAVERSAL`.
Invalid registration paths fail with `INVALID_PATH` or `PATH_TRAVERSAL`.

**Why keys:**

Expand Down Expand Up @@ -252,6 +261,51 @@ let _plugin = Builder::new()
# }
```

A key whose path is not known until after plugin setup (for example, a per-profile
database created only after the profile exists, and is not available on startup of the
app's first launch) is registered on the running `AppHandle`. Pass `IfExists::Error`
for a new key. `IfExists::Replace` on a key that was never registered inserts that key.
`IfExists::Replace` on a registered key closes the loaded connection and points that
key at a different file. The same path, including after a failed migration, or a path
already stored for another key, is `INVALID_CONFIG` and leaves the loaded database in
place. A same-path `Replace` does not run the migrator again. Leave a failed path by
`Replace` to a different path, or restart the app. The call stores the path and
returns. Call `connect` to open it. A `Some` migrator is spawned, and `connect` waits
for it. `None` clears migration state for that key. Subscriptions and observers for
the key are cleared when the path change closes the loaded database. Stop and drain
frontend writes on that key before `Replace`. The plugin then emits
`sqlite:path-replaced` (`dbKey`, `dbPath`). Each `Database` instance for that key
clears its cached path, so the next `load()` connects again. The event does not
cancel a write already in flight. Until something reconnects the key, commands return
`DATABASE_NOT_LOADED`. After a reconnect, every `Database` instance for that key,
including ones created before the replace, sends commands to the new file.
`getPath()` stays at the old path until that instance calls `load()` again.
`close()` closes the current pool for the key. `remove()` deletes the current files
for the key. The window must call `observe` and `subscribe` again.

```rust
use tauri::{AppHandle, Runtime};
use tauri_plugin_sqlite::{Connection, DatabaseRegistration, IfExists};

async fn open_profile_notes<R: Runtime>(
app: &AppHandle<R>,
notes_path: std::path::PathBuf,
) -> tauri_plugin_sqlite::Result<()> {
app.register_database("NOTES", notes_path, None, IfExists::Error).await?;
let _db = app.connect("NOTES").await?;
Ok(())
}

async fn switch_profile_notes<R: Runtime>(
app: &AppHandle<R>,
notes_path: std::path::PathBuf,
) -> tauri_plugin_sqlite::Result<()> {
app.register_database("NOTES", notes_path, None, IfExists::Replace).await?;
let _db = app.connect("NOTES").await?;
Ok(())
}
```

The frontend then calls `Database.load(MAIN_DB_KEY)` (see [Connecting](#connecting)).

### Migrations
Expand Down Expand Up @@ -699,6 +753,15 @@ await db.observe(['users'], {
dropping subscribers. Omitting either field inherits the active value; an
explicit _different_ value fails with `OBSERVATION_CONFIG_CONFLICT`. To change
them, every window must `unobserve()` first
* A Rust `IfExists::Replace` that changes a loaded key closes that key the same way
`close()` does: subscriptions abort and observation stops until the window calls
`observe()` again. Stop and drain frontend writes on that key before `Replace`.
The plugin emits `sqlite:path-replaced`. Each `Database` instance for the key
clears its cached path. The event does not cancel a write already in flight.
Until something reconnects the key, commands return `DATABASE_NOT_LOADED`. After
a reconnect, those instances send commands to the new file. `getPath()` stays at
the old path until that instance calls `load()` again. `close()` closes the
current pool. `remove()` deletes the current files
* **Observation is database-wide, not per-connection.** The broker belongs to
the underlying database file, keyed by its canonical path — not to any one
way of opening it. Every window that loads the same database key shares it,
Expand Down Expand Up @@ -822,6 +885,15 @@ await Database.close_all(); // Close all connections
await db.remove(); // Close and DELETE database file(s) - irreversible!
```

A Rust `IfExists::Replace` that changes a loaded key closes that key the same way
`close()` does. Stop and drain frontend writes on that key before `Replace`. The
plugin emits `sqlite:path-replaced`. Each `Database` instance for the key clears its
cached path. The event does not cancel a write already in flight. Until something
reconnects the key, commands return `DATABASE_NOT_LOADED`. After a reconnect, those
instances send commands to the new file. `getPath()` stays at the old path until that
instance calls `load()` again. `close()` closes the current pool. `remove()` deletes
the current files.

## API Reference

### Static Methods
Expand Down Expand Up @@ -956,7 +1028,9 @@ from [`sqlx-sqlite-toolkit`](crates/sqlx-sqlite-toolkit/) directly (no registrat

```rust
use tauri::{Manager, Runtime};
use tauri_plugin_sqlite::{Builder, Connection, SqliteDatabaseConfig};
use tauri_plugin_sqlite::{
Builder, Connection, DatabaseRegistration, IfExists, SqliteDatabaseConfig,
};

const MAIN_DB_KEY: &str = "MAIN";

Expand All @@ -983,6 +1057,19 @@ async fn example<R: Runtime>(app: tauri::AppHandle<R>) -> tauri_plugin_sqlite::R

Ok(())
}

// After setup, register a key that was not known at startup.
// IfExists::Error inserts a new key. IfExists::Replace points an existing key at another file.
async fn register_later<R: Runtime>(
app: &tauri::AppHandle<R>,
notes_path: std::path::PathBuf,
other_path: std::path::PathBuf,
) -> tauri_plugin_sqlite::Result<()> {
app.register_database("NOTES", notes_path, None, IfExists::Error).await?;
app.register_database("NOTES", other_path, None, IfExists::Replace).await?;
let _db = app.connect("NOTES").await?;
Ok(())
}
```

### Scalar Functions
Expand Down
Loading
Loading