diff --git a/CHANGELOG.md b/CHANGELOG.md index 8250d72..d72a501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2fa74b7..9a157ff 100644 --- a/README.md +++ b/README.md @@ -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**. @@ -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:** @@ -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( + app: &AppHandle, + 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( + app: &AppHandle, + 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 @@ -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, @@ -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 @@ -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"; @@ -983,6 +1057,19 @@ async fn example(app: tauri::AppHandle) -> 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( + app: &tauri::AppHandle, + 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 diff --git a/api-iife.js b/api-iife.js index 2b58111..eb50d6d 100644 --- a/api-iife.js +++ b/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_SQLITE__=function(t){"use strict";function e(t,e,s,i){if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===s?i:"a"===s?i.call(t):i?i.value:e.get(t)}function s(t,e,s,i,a){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,s),s}var i,a,n,h;"function"==typeof SuppressedError&&SuppressedError;const r="__TAURI_TO_IPC_KEY__";class c{constructor(t){i.set(this,void 0),a.set(this,0),n.set(this,[]),h.set(this,void 0),s(this,i,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}(t=>{const r=t.index;if("end"in t)return void(r==e(this,a,"f")?this.cleanupCallback():s(this,h,r));const c=t.message;if(r==e(this,a,"f")){for(e(this,i,"f").call(this,c),s(this,a,e(this,a,"f")+1);e(this,a,"f")in e(this,n,"f");){const t=e(this,n,"f")[e(this,a,"f")];e(this,i,"f").call(this,t),delete e(this,n,"f")[e(this,a,"f")],s(this,a,e(this,a,"f")+1)}e(this,a,"f")===e(this,h,"f")&&this.cleanupCallback()}else e(this,n,"f")[r]=c})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){s(this,i,t)}get onmessage(){return e(this,i,"f")}[(i=new WeakMap,a=new WeakMap,n=new WeakMap,h=new WeakMap,r)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[r]()}}async function u(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}async function _(t){await t.load()}class l{constructor(t,e){this._dbKey=t,this._transactionId=e}async read(t,e){return await u("plugin:sqlite|transaction_read",{token:{dbKey:this._dbKey,transactionId:this._transactionId},query:t,values:e??[]})}async continueWith(t){const e=await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Continue",statements:t.map(([t,e])=>({query:t,values:e??[]}))}});return new l(e.dbKey,e.transactionId)}async commit(){await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Commit"}})}async rollback(){await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Rollback"}})}}class o{constructor(t){this._subscriptionId=t}get id(){return this._subscriptionId}async unsubscribe(){return await u("plugin:sqlite|unsubscribe",{subscriptionId:this._subscriptionId})}}class d{constructor(t,e,s,i=[]){this._db=t,this._query=e,this._bindValues=s,this._attached=i}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await _(this._db),await u("plugin:sqlite|fetch_all",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null})}}class b{constructor(t,e,s,i=[]){this._db=t,this._query=e,this._bindValues=s,this._attached=i}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await _(this._db),await u("plugin:sqlite|fetch_one",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null})}}class y{constructor(t,e,s,i,a){this._db=t,this._query=e,this._bindValues=s,this._keyset=i,this._pageSize=a,this._after=null,this._before=null,this._attached=[]}after(t){return this._after=t,this}before(t){return this._before=t,this}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await _(this._db),await u("plugin:sqlite|fetch_page",{dbKey:this._db.key,query:this._query,values:this._bindValues,keyset:this._keyset,pageSize:this._pageSize,after:this._after,before:this._before,attached:this._attached.length>0?this._attached:null})}}class w{constructor(t,e,s,i=[]){this._db=t,this._query=e,this._bindValues=s,this._attached=i}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){await _(this._db);const[t,e]=await u("plugin:sqlite|execute",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null});return{lastInsertId:e,rowsAffected:t}}}class p{constructor(t,e,s=[]){this._db=t,this._initialStatements=e,this._attached=s}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){await _(this._db);const t=await u("plugin:sqlite|begin_interruptible_transaction",{dbKey:this._db.key,initialStatements:this._initialStatements.map(([t,e])=>({query:t,values:e??[]})),attached:this._attached.length>0?this._attached:null});return new l(t.dbKey,t.transactionId)}}class f{constructor(t,e,s=[]){this._db=t,this._statements=e,this._attached=s}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await _(this._db),await u("plugin:sqlite|execute_transaction",{dbKey:this._db.key,statements:this._statements.map(([t,e])=>({query:t,values:e??[]})),attached:this._attached.length>0?this._attached:null})}}class g{constructor(t){this._path=null,this._loadPromise=null,this.key=t}static async load(t,e){const s=new g(t);return await s.load(e),s}static get(t){return new g(t)}static async close_all(){await u("plugin:sqlite|close_all")}getPath(){return this._path}async load(t){if(null===this._path){if(this._loadPromise)return this._loadPromise;this._loadPromise=(async()=>{try{const e=await u("plugin:sqlite|load",{dbKey:this.key,customConfig:t});this._path=e}catch(t){throw this._loadPromise=null,t}})(),await this._loadPromise}}execute(t,e){return new w(this,t,e??[])}executeTransaction(t){return new f(this,t)}fetchAll(t,e){return new d(this,t,e??[])}fetchOne(t,e){return new b(this,t,e??[])}fetchPage(t,e,s,i){return new y(this,t,e,s,i)}async observe(t,e){await _(this),await u("plugin:sqlite|observe",{dbKey:this.key,tables:t,config:e??null})}async subscribe(t,e){await _(this);const s=new c;s.onmessage=e;const i=await u("plugin:sqlite|subscribe",{dbKey:this.key,tables:t,onEvent:s});return new o(i)}async unobserve(){await _(this),await u("plugin:sqlite|unobserve",{dbKey:this.key})}async close(){const t=await u("plugin:sqlite|close",{dbKey:this.key});return t&&this._resetLoadState(),t}async remove(){const t=await u("plugin:sqlite|remove",{dbKey:this.key});return t&&this._resetLoadState(),t}beginInterruptibleTransaction(t){return new p(this,t)}async getMigrationEvents(){return await u("plugin:sqlite|get_migration_events",{dbKey:this.key})}_resetLoadState(){this._path=null,this._loadPromise=null}}return t.InterruptibleTransaction=l,t.Subscription=o,t.default=g,Object.defineProperty(t,"__esModule",{value:!0}),t}({});Object.defineProperty(window.__TAURI__,"sqlite",{value:__TAURI_PLUGIN_SQLITE__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_SQLITE__=function(t){"use strict";function e(t,e,i,s){if("function"==typeof e?t!==e||!s:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===i?s:"a"===i?s.call(t):s?s.value:e.get(t)}function i(t,e,i,s,a){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,i),i}var s,a,n,r;"function"==typeof SuppressedError&&SuppressedError;const h="__TAURI_TO_IPC_KEY__";function _(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}class c{constructor(t){s.set(this,void 0),a.set(this,0),n.set(this,[]),r.set(this,void 0),i(this,s,t||(()=>{})),this.id=_(t=>{const h=t.index;if("end"in t)return void(h==e(this,a,"f")?this.cleanupCallback():i(this,r,h));const _=t.message;if(h==e(this,a,"f")){for(e(this,s,"f").call(this,_),i(this,a,e(this,a,"f")+1);e(this,a,"f")in e(this,n,"f");){const t=e(this,n,"f")[e(this,a,"f")];e(this,s,"f").call(this,t),delete e(this,n,"f")[e(this,a,"f")],i(this,a,e(this,a,"f")+1)}e(this,a,"f")===e(this,r,"f")&&this.cleanupCallback()}else e(this,n,"f")[h]=_})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){i(this,s,t)}get onmessage(){return e(this,s,"f")}[(s=new WeakMap,a=new WeakMap,n=new WeakMap,r=new WeakMap,h)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[h]()}}async function u(t,e={},i){return window.__TAURI_INTERNALS__.invoke(t,e,i)}var l;async function o(t,e,i){const s={kind:"Any"};return u("plugin:event|listen",{event:t,target:s,handler:_(e)}).then(e=>async()=>async function(t,e){window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(t,e),await u("plugin:event|unlisten",{event:t,eventId:e})}(t,e))}async function d(t){await t.load()}!function(t){t.WINDOW_RESIZED="tauri://resize",t.WINDOW_MOVED="tauri://move",t.WINDOW_CLOSE_REQUESTED="tauri://close-requested",t.WINDOW_DESTROYED="tauri://destroyed",t.WINDOW_FOCUS="tauri://focus",t.WINDOW_BLUR="tauri://blur",t.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",t.WINDOW_THEME_CHANGED="tauri://theme-changed",t.WINDOW_CREATED="tauri://window-created",t.WINDOW_SUSPENDED="tauri://suspended",t.WINDOW_RESUMED="tauri://resumed",t.WEBVIEW_CREATED="tauri://webview-created",t.DRAG_ENTER="tauri://drag-enter",t.DRAG_OVER="tauri://drag-over",t.DRAG_DROP="tauri://drag-drop",t.DRAG_LEAVE="tauri://drag-leave"}(l||(l={}));class y{constructor(t,e){this._dbKey=t,this._transactionId=e}async read(t,e){return await u("plugin:sqlite|transaction_read",{token:{dbKey:this._dbKey,transactionId:this._transactionId},query:t,values:e??[]})}async continueWith(t){const e=await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Continue",statements:t.map(([t,e])=>({query:t,values:e??[]}))}});return new y(e.dbKey,e.transactionId)}async commit(){await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Commit"}})}async rollback(){await u("plugin:sqlite|transaction_continue",{token:{dbKey:this._dbKey,transactionId:this._transactionId},action:{type:"Rollback"}})}}class b{constructor(t){this._subscriptionId=t}get id(){return this._subscriptionId}async unsubscribe(){return await u("plugin:sqlite|unsubscribe",{subscriptionId:this._subscriptionId})}}class p{constructor(t,e,i,s=[]){this._db=t,this._query=e,this._bindValues=i,this._attached=s}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await d(this._db),await u("plugin:sqlite|fetch_all",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null})}}class w{constructor(t,e,i,s=[]){this._db=t,this._query=e,this._bindValues=i,this._attached=s}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await d(this._db),await u("plugin:sqlite|fetch_one",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null})}}class f{constructor(t,e,i,s,a){this._db=t,this._query=e,this._bindValues=i,this._keyset=s,this._pageSize=a,this._after=null,this._before=null,this._attached=[]}after(t){return this._after=t,this}before(t){return this._before=t,this}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await d(this._db),await u("plugin:sqlite|fetch_page",{dbKey:this._db.key,query:this._query,values:this._bindValues,keyset:this._keyset,pageSize:this._pageSize,after:this._after,before:this._before,attached:this._attached.length>0?this._attached:null})}}class g{constructor(t,e,i,s=[]){this._db=t,this._query=e,this._bindValues=i,this._attached=s}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){await d(this._db);const[t,e]=await u("plugin:sqlite|execute",{dbKey:this._db.key,query:this._query,values:this._bindValues,attached:this._attached.length>0?this._attached:null});return{lastInsertId:e,rowsAffected:t}}}class I{constructor(t,e,i=[]){this._db=t,this._initialStatements=e,this._attached=i}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){await d(this._db);const t=await u("plugin:sqlite|begin_interruptible_transaction",{dbKey:this._db.key,initialStatements:this._initialStatements.map(([t,e])=>({query:t,values:e??[]})),attached:this._attached.length>0?this._attached:null});return new y(t.dbKey,t.transactionId)}}class E{constructor(t,e,i=[]){this._db=t,this._statements=e,this._attached=i}attach(t){return this._attached=t,this}then(t,e){return this._execute().then(t,e)}async _execute(){return await d(this._db),await u("plugin:sqlite|execute_transaction",{dbKey:this._db.key,statements:this._statements.map(([t,e])=>({query:t,values:e??[]})),attached:this._attached.length>0?this._attached:null})}}class m{constructor(t){this._path=null,this._loadedGeneration=0,this._loadPromise=null,this.key=t,m._ensure_path_replaced_listener()}static async load(t,e){const i=new m(t);return await i.load(e),i}static get(t){return new m(t)}static async close_all(){await u("plugin:sqlite|close_all")}static _ensure_path_replaced_listener(){if(null!==m._path_replaced_listener)return;const t=o("sqlite:path-replaced",t=>{const e=t.payload.dbKey;m._path_generations.set(e,(m._path_generations.get(e)??0)+1)});m._path_replaced_listener=t,t.catch(()=>{m._path_replaced_listener===t&&(m._path_replaced_listener=null)})}getPath(){return this._path}async load(t){const e=m._path_generations.get(this.key)??0;if(null!==this._path&&this._loadedGeneration!==e&&this._resetLoadState(),null===this._path){if(this._loadPromise)return await this._loadPromise,this._loadedGeneration!==(m._path_generations.get(this.key)??0)?(this._resetLoadState(),this.load(t)):void 0;this._loadPromise=(async()=>{try{const i=await u("plugin:sqlite|load",{dbKey:this.key,customConfig:t});this._path=i,this._loadedGeneration=e}catch(t){throw this._loadPromise=null,t}})(),await this._loadPromise}}execute(t,e){return new g(this,t,e??[])}executeTransaction(t){return new E(this,t)}fetchAll(t,e){return new p(this,t,e??[])}fetchOne(t,e){return new w(this,t,e??[])}fetchPage(t,e,i,s){return new f(this,t,e,i,s)}async observe(t,e){await d(this),await u("plugin:sqlite|observe",{dbKey:this.key,tables:t,config:e??null})}async subscribe(t,e){await d(this);const i=new c;i.onmessage=e;const s=await u("plugin:sqlite|subscribe",{dbKey:this.key,tables:t,onEvent:i});return new b(s)}async unobserve(){await d(this),await u("plugin:sqlite|unobserve",{dbKey:this.key})}async close(){const t=await u("plugin:sqlite|close",{dbKey:this.key});return t&&this._resetLoadState(),t}async remove(){const t=await u("plugin:sqlite|remove",{dbKey:this.key});return t&&this._resetLoadState(),t}beginInterruptibleTransaction(t){return new I(this,t)}async getMigrationEvents(){return await u("plugin:sqlite|get_migration_events",{dbKey:this.key})}_resetLoadState(){this._path=null,this._loadPromise=null,this._loadedGeneration=0}}return m._path_generations=new Map,m._path_replaced_listener=null,t.InterruptibleTransaction=y,t.Subscription=b,t.default=m,Object.defineProperty(t,"__esModule",{value:!0}),t}({});Object.defineProperty(window.__TAURI__,"sqlite",{value:__TAURI_PLUGIN_SQLITE__})} diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index c3c4e3b..d234173 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -114,7 +114,7 @@ beforeEach(() => { return undefined; } return undefined; - }); + }, { shouldMockEvents: true }); }); afterEach(() => { return clearMocks(); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index 3c4f5ca..f030958 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1,4 +1,5 @@ import { Channel, invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; async function ensureDatabaseLoaded(db: Database): Promise { await db.load(); @@ -278,6 +279,33 @@ export interface MigrationEvent { error?: string; } +/** + * Payload for the `sqlite:path-replaced` event. + * + * Rust emits this after `IfExists::Replace` stores a new path for a key that + * was already registered. Each `Database` instance for that key clears its + * cached path. A write already in flight is not cancelled. + * + * @example + * ```ts + * import { listen } from '@tauri-apps/api/event' + * import type { PathReplacedEvent } from '@silvermine/tauri-plugin-sqlite' + * + * await listen('sqlite:path-replaced', (event) => { + * const { dbKey, dbPath } = event.payload + * console.log(`${dbKey} now points at ${dbPath}`) + * }) + * ``` + */ +export interface PathReplacedEvent { + + /** Database registration key (such as `MAIN`) */ + dbKey: string; + + /** Canonical path now stored for `dbKey` */ + dbPath: string; +} + // ─── Pagination Types ─── /** @@ -807,6 +835,10 @@ class TransactionBuilder implements PromiseLike { */ export default class Database { + private static _path_generations = new Map(); + + private static _path_replaced_listener: Promise | null = null; + /** Database key for identifying the database */ public readonly key: string; @@ -818,10 +850,13 @@ export default class Database { */ private _path: string | null = null; + private _loadedGeneration = 0; + private _loadPromise: Promise | null = null; public constructor(key: string) { this.key = key; + Database._ensure_path_replaced_listener(); } /** @@ -831,7 +866,8 @@ export default class Database { * returns a `Database` instance once a connection is established. * * The key must be a key that the Rust side has registered with the plugin - * (via `Builder::register_database` / `SetupRegistrar::register_database`), + * (via `Builder::register_database`, `SetupRegistrar::register_database`, or + * `AppHandle::register_database`), * or an in-memory database such as `:memory:`. * * @param key - The key of the database to load @@ -870,7 +906,8 @@ export default class Database { * `customConfig` or connect eagerly. * * The key must be registered with the plugin on the Rust side - * (via `Builder::register_database` / `SetupRegistrar::register_database`). + * (via `Builder::register_database`, `SetupRegistrar::register_database`, or + * `AppHandle::register_database`). * * @example * ```ts @@ -896,6 +933,32 @@ export default class Database { await invoke('plugin:sqlite|close_all'); } + private static _ensure_path_replaced_listener(): void { + if (Database._path_replaced_listener !== null) { + return; + } + + const listenPromise = listen( + 'sqlite:path-replaced', + (event) => { + const dbKey = event.payload.dbKey; + + Database._path_generations.set( + dbKey, + (Database._path_generations.get(dbKey) ?? 0) + 1 + ); + } + ); + + Database._path_replaced_listener = listenPromise; + + listenPromise.catch(() => { + if (Database._path_replaced_listener === listenPromise) { + Database._path_replaced_listener = null; + } + }); + } + /** * **path** * @@ -916,9 +979,21 @@ export default class Database { * returns a `Database` instance once a connection is established. * * The key must be a key that the Rust side has registered with the plugin - * (via `Builder::register_database` / `SetupRegistrar::register_database`), + * (via `Builder::register_database`, `SetupRegistrar::register_database`, or + * `AppHandle::register_database`), * or an in-memory database such as `:memory:`. * + * A Rust `IfExists::Replace` emits `sqlite:path-replaced` with a + * {@link PathReplacedEvent}. That event bumps a per-key generation counter. + * This instance records the generation when `load()` finishes. A later + * `load()` sees a newer generation, drops the cached path, and connects + * again. The event does not cancel a write already in flight. Drain writes + * on this key before `Replace`. After any reconnect, commands on this + * instance use the key's current file. `getPath()` stays at the previous + * path until `load()` runs again. An event that arrives while `load()` is + * in flight leaves this instance on the generation it started with, so the + * next `load()` connects again. + * * @param key - The key of the database to load * @param customConfig - Optional custom configuration for connection pools * @@ -939,12 +1014,25 @@ export default class Database { public async load( customConfig?: CustomConfig ): Promise { + const generation = Database._path_generations.get(this.key) ?? 0; + + if (this._path !== null && this._loadedGeneration !== generation) { + this._resetLoadState(); + } + if (this._path !== null) { return; } if (this._loadPromise) { - return this._loadPromise; + await this._loadPromise; + + if (this._loadedGeneration !== (Database._path_generations.get(this.key) ?? 0)) { + this._resetLoadState(); + return this.load(customConfig); + } + + return; } this._loadPromise = (async () => { @@ -955,6 +1043,9 @@ export default class Database { }); this._path = resolvedPath; + // Generation was read before invoke. An event during the call + // leaves this instance behind the counter, so the next load() connects again. + this._loadedGeneration = generation; } catch(err) { this._loadPromise = null; throw err; @@ -1519,5 +1610,6 @@ export default class Database { private _resetLoadState(): void { this._path = null; this._loadPromise = null; + this._loadedGeneration = 0; } } diff --git a/src/commands.rs b/src/commands.rs index d1be503..1bc9983 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -184,14 +184,16 @@ fn resolve_attached_specs( /// If the database is already loaded, returns the existing connection. /// Otherwise, creates a new connection with optional custom configuration. /// -/// `db_key` must be a registration key from -/// [`Builder::register_database`] / [`SetupRegistrar::register_database`]. +/// `db_key` must be a registration key from [`crate::Builder::register_database`], +/// [`crate::SetupRegistrar::register_database`], or +/// [`crate::DatabaseRegistration::register_database`]. /// Unregistered keys are rejected with `PATH_NOT_REGISTERED`. /// /// # Migration Timing /// /// If migrations are registered for this database, this function waits for them -/// to complete before proceeding. The migration task (spawned at plugin setup) +/// to complete before proceeding. The migration task is spawned at plugin setup +/// or by [`crate::DatabaseRegistration::register_database`]. That task /// already called `SqliteDatabase::connect()`, which cached the database instance. /// When we call `connect()` here, we get the **same cached instance** from the /// registry - so we're not creating duplicate connections. diff --git a/src/lib.rs b/src/lib.rs index c976f55..c027239 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use serde::Serialize; use sqlx_sqlite_conn_mgr::Migrator; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, plugin::Builder as PluginBuilder}; -use tokio::sync::{Notify, RwLock}; +use tokio::sync::{Mutex, Notify, RwLock}; use tracing::{debug, error, info, trace, warn}; mod commands; @@ -88,7 +88,7 @@ impl DbInstances { } } -/// Tracks the paths of all registered databases. +/// Tracks every registered database key and its path. /// The String value of the key is the database identifier, not the path. /// For example, the value of the key `MAIN` would be something like /// `/var/lib/myapp/main.db`. @@ -96,9 +96,25 @@ impl DbInstances { /// This key value is what will be used by the caller to interact with the database. /// For example, when calling `load()` or `execute()`, the caller will pass the key value /// to identify the database to which they want to connect. -#[derive(Clone, Default)] +/// +/// `paths` is the registration map. `registration_lock` is held for +/// [`DatabaseRegistration::register_database`] and for the connect section from path +/// lookup through pool insert, so a path change cannot land between those two steps. +/// [`DatabaseRegistration::register_database`] drops the lock while it waits for this +/// key's migration to leave `Pending` or `Running`, then takes the lock and checks +/// the path map again. The lock does not cover that migration wait. pub struct RegisteredDatabases { - pub(crate) database_path_by_key: Arc>, + paths: RwLock>, + registration_lock: Mutex<()>, +} + +impl Default for RegisteredDatabases { + fn default() -> Self { + Self { + paths: RwLock::new(HashMap::new()), + registration_lock: Mutex::new(()), + } + } } /// Contains the information required for registering a database. @@ -106,11 +122,9 @@ pub struct RegisteredDatabases { /// When initializing or setting up the plugin, the caller will pass the path to the database /// file and the migrator to use for the database. /// -/// This information is then stored in the `RegisteredDatabases` struct, which is used to -/// track the paths of all registered databases. -/// -/// The `migrator` is not held by the app state, but rather is only used after -/// initialization to run the migrations for the database. +/// Plugin setup and [`DatabaseRegistration::register_database`] spawn the migrator +/// from this value when it is `Some`. A `None` argument clears any migration state +/// stored for that key. [`RegisteredDatabases`] stores the path only. #[derive(Debug, Clone)] struct DatabaseInfo { path: PathBuf, @@ -136,16 +150,20 @@ fn ensure_distinct_database_paths( for (key, info) in database_info_by_key { if let Some(existing_key) = path_to_key.insert(info.path.clone(), key.as_str()) { - return Err(Error::InvalidConfig(format!( - "database keys {existing_key} and {key} both register the same path: {}", - info.path.display() - ))); + return Err(duplicate_path_config(existing_key, key, &info.path)); } } Ok(()) } +fn duplicate_path_config(existing_key: &str, key: &str, path: &Path) -> Error { + Error::InvalidConfig(format!( + "database keys {existing_key} and {key} both register the same path: {}", + path.display() + )) +} + /// Migration status for a database. #[derive(Debug, Clone)] pub enum MigrationStatus { @@ -190,6 +208,13 @@ impl MigrationState { #[derive(Default)] pub struct MigrationStates(pub RwLock>); +/// When set, `run_migrations_for_database` for key `MAIN` waits at [`tokio::sync::Barrier::wait`] +/// after setting [`MigrationStatus::Running`], so tests can run another `connect` +/// while migration is mid-flight. +#[cfg(test)] +static TEST_MAIN_MIGRATION_RUNNING_BARRIER: std::sync::Mutex>> = + std::sync::Mutex::new(None); + /// Event payload emitted during migration operations. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -210,6 +235,18 @@ pub struct MigrationEvent { pub error: Option, } +/// Event payload emitted after [`IfExists::Replace`] stores a new path for a key. +/// +/// The frontend event name is `sqlite:path-replaced`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PathReplacedEvent { + /// Database key whose path changed. + pub db_key: String, + /// Canonical path now stored for `db_key`. + pub db_path: PathBuf, +} + /// Builder for the SQLite plugin. /// /// Use this to configure the plugin and build the plugin instance. @@ -285,7 +322,8 @@ pub struct SetupRegistrar { } impl SetupRegistrar { - /// Register a database path, optionally with migrations. See [`Builder::register_database`]. + /// Register a database key and path, optionally with migrations. See + /// [`Builder::register_database`]. /// /// This invocation is to be used when the database path is known at runtime (such as /// a path dependent on `app.path().app_data_dir()`). @@ -419,6 +457,8 @@ impl Builder { /// Distinct keys must map to distinct database paths. If two distinct keys register /// the same path, plugin initialization returns [`Error::InvalidConfig`]. Registrations /// from [`on_setup`](Self::on_setup) are validated when the merged map is initialized. + /// [`DatabaseRegistration::register_database`] rejects a duplicate path the same + /// way after setup. pub fn register_database( mut self, key: &str, @@ -594,7 +634,8 @@ impl Builder { /// /// Duplicate paths across distinct registration keys are rejected during plugin /// initialization (the setup hook), after [`on_setup`](Self::on_setup) registrations - /// are merged with static ones. + /// are merged with static ones. The same check runs for registrations and path + /// changes made after setup. pub fn build(self) -> Result> { let database_info_by_key = self.database_info_by_key; let transaction_timeout = self.transaction_timeout; @@ -647,8 +688,13 @@ impl Builder { ensure_distinct_database_paths(&database_info_by_key)?; + let paths = database_info_by_key + .iter() + .map(|(key, info)| (key.clone(), info.path.clone())) + .collect(); app.manage(RegisteredDatabases { - database_path_by_key: Arc::new(database_info_by_key.iter().map(|(key, info)| (key.clone(), info.path.clone())).collect()), + paths: RwLock::new(paths), + registration_lock: Mutex::new(()), }); let migration_states = app.state::(); @@ -896,8 +942,19 @@ async fn run_migrations_for_database( // Emit running event emit_migration_event(&app, key, path, "running", None, None); + #[cfg(test)] + if key == "MAIN" { + let barrier = TEST_MAIN_MIGRATION_RUNNING_BARRIER + .lock() + .expect("test migration barrier lock") + .clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + } + } + // Resolve absolute path and connect - let abs_path = match resolve_database_path(key, &app) { + let abs_path = match resolve_database_path(key, &app).await { Ok(p) => p, Err(e) => { let error_msg = e.to_string(); @@ -995,6 +1052,20 @@ fn emit_migration_event( } } +/// Tell guest handles that `db_key` now points at `db_path`. +/// +/// Emit failure is logged. The registration itself already succeeded. +fn emit_path_replaced_event(app: &AppHandle, db_key: &str, db_path: &Path) { + let event = PathReplacedEvent { + db_key: db_key.to_string(), + db_path: db_path.to_path_buf(), + }; + + if let Err(e) = app.emit("sqlite:path-replaced", &event) { + warn!("Failed to emit path replaced event: {}", e); + } +} + /// Connect to a registered database by its registration key. /// /// Opens the database through the same path as the frontend `load` IPC command @@ -1003,7 +1074,8 @@ fn emit_migration_event( /// toolkit use. /// /// The `database_key` must match a key registered via -/// [`Builder::register_database`] or [`SetupRegistrar::register_database`]. +/// [`Builder::register_database`], [`SetupRegistrar::register_database`], or +/// [`DatabaseRegistration::register_database`]. /// /// # Why use a key? /// @@ -1072,6 +1144,57 @@ pub trait Connection { fn close(&self, database_key: &str) -> impl Future> + Send; } +/// What [`DatabaseRegistration::register_database`] does when `key` is already registered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IfExists { + /// Return [`Error::InvalidConfig`] and leave the stored registration alone. + Error, + /// Point `key` at a different database file. + /// + /// An unknown key is inserted. No close runs, because nothing is loaded. + /// + /// The canonical path this key already has, and a path stored for another key, + /// both return [`Error::InvalidConfig`] with no other change. That same-path + /// rejection also applies when this key's migration status is `Failed`. A + /// same-path `Replace` does not run the migrator again. + Replace, +} + +/// Register databases after plugin setup. +pub trait DatabaseRegistration { + /// Register a database after plugin setup. + /// + /// Path rules match [`Builder::register_database`]: absolute + /// file path, no `..`, canonicalized, parent directory created when missing. + /// In-memory URIs are accepted as-is. This call stores the registration and + /// returns. It does not open the database. [`Connection::connect`] waits when + /// a migrator was spawned. + /// + /// `IfExists::Error` returns [`Error::InvalidConfig`] when `key` is already + /// registered. `IfExists::Replace` closes a loaded database and stores `path` + /// when that path is different. The same canonical path, or a path already + /// stored for another key, returns [`Error::InvalidConfig`] and leaves the + /// loaded database in place. A same-path `Replace` is rejected after + /// `Complete` and after `Failed`. It does not run the migrator again. Leave a + /// failed path by `Replace` to a different path, or restart the app. + /// + /// Close failure returns that error and keeps the stored path. The close + /// removes the loaded instance and clears subscriptions before the pool + /// close finishes, so a close failure can leave the key unloaded. A later + /// [`Connection::connect`] reopens the stored path. A `Some` migrator + /// replaces this key's migration state and is spawned the same way plugin + /// setup spawns one. `None` removes this key's migration state. Migration + /// failure is reported by a later [`Connection::connect`], with the key left + /// on the stored path. A successful path change emits `sqlite:path-replaced`. + fn register_database( + &self, + key: &str, + path: impl Into + Send, + migrator: Option, + if_exists: IfExists, + ) -> impl Future> + Send; +} + /// Delegates to [`connect_to_database`]: same open path as the `load` IPC command. impl Connection for AppHandle { async fn connect(&self, database_key: &str) -> Result { @@ -1119,6 +1242,108 @@ impl Connection for AppHandle { } } +impl DatabaseRegistration for AppHandle { + async fn register_database( + &self, + key: &str, + path: impl Into + Send, + migrator: Option, + if_exists: IfExists, + ) -> Result<()> { + let registered = self + .try_state::() + .ok_or(Error::MissingState("RegisteredDatabases".into()))?; + let migration_states = self + .try_state::() + .ok_or(Error::MissingState("MigrationStates".into()))?; + // Path validation does not depend on the registration map. Map checks run + // again after every lock, because a wait drops the lock and those checks + // would otherwise observe a stale map. + let info = validated_database_info(path, migrator)?; + + loop { + let _registration_lock = registered.registration_lock.lock().await; + + if migration_is_unsettled(&migration_states, key).await { + drop(_registration_lock); + wait_until_migration_settled(&migration_states, key).await; + continue; + } + + if if_exists == IfExists::Error { + let paths = registered.paths.read().await; + if paths.contains_key(key) { + return Err(Error::InvalidConfig(format!( + "database key {key} is already registered" + ))); + } + } + + let current_path = { + let paths = registered.paths.read().await; + if let Some(existing_key) = paths.iter().find_map(|(existing_key, existing_path)| { + (existing_key != key && existing_path == &info.path).then_some(existing_key.as_str()) + }) { + return Err(duplicate_path_config(existing_key, key, &info.path)); + } + paths.get(key).cloned() + }; + + let replaced = if let Some(current_path) = current_path { + if current_path == info.path { + return Err(Error::InvalidConfig(format!( + "database key {key} is already registered at {}", + info.path.display() + ))); + } + + // `Failed` is settled, so a replace can leave that path. `Pending` + // and `Running` already waited above, outside this lock. + Connection::close(self, key).await?; + true + } else { + false + }; + + { + let mut paths = registered.paths.write().await; + paths.insert(key.to_string(), info.path.clone()); + } + + spawn_or_clear_migrations(self, &migration_states, key, &info.path, info.migrator).await; + + if replaced { + emit_path_replaced_event(self, key, &info.path); + } + return Ok(()); + } + } +} + +async fn spawn_or_clear_migrations( + app: &AppHandle, + migration_states: &MigrationStates, + key: &str, + path: &Path, + migrator: Option>, +) { + if let Some(migrator) = migrator { + migration_states + .0 + .write() + .await + .insert(key.to_string(), MigrationState::new()); + let key = key.to_string(); + let path = path.to_path_buf(); + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + run_migrations_for_database(app_handle, &key, &path, &migrator).await; + }); + } else { + migration_states.0.write().await.remove(key); + } +} + struct ConnectionResponse { path: PathBuf, wrapper: DatabaseWrapper, @@ -1130,12 +1355,35 @@ async fn connect_to_database( custom_config: Option, ) -> Result { let migration_states = app.state::(); - let db_instances = app.state::(); + let registered = app.state::(); + + loop { + // Migration waits must not hold `registration_lock`: one key's long-running + // migration would block every `connect`, `load`, and `register_database`. + await_migrations(&migration_states, db_key).await?; + + let _registration_lock = registered.registration_lock.lock().await; + + if migration_is_unsettled(&migration_states, db_key).await { + drop(_registration_lock); + wait_until_migration_settled(&migration_states, db_key).await; + continue; + } + + // Settled for this key under the lock. Do not call `await_migrations` here: it + // would wait on `Pending`/`Running` while holding the global registration mutex. + return connect_to_database_locked(app, db_key, custom_config).await; + } +} - // Wait for migrations to complete if registered for this database - await_migrations(&migration_states, db_key).await?; +async fn connect_to_database_locked( + app: &AppHandle, + db_key: &str, + custom_config: Option, +) -> Result { + let db_instances = app.state::(); - let path = resolve_database_path(db_key, app)?; + let path = resolve_database_path(db_key, app).await?; let instances = db_instances.inner.read().await; @@ -1187,31 +1435,71 @@ async fn connect_to_database( /// Returns Err if migrations failed. async fn await_migrations(migration_states: &MigrationStates, db_key: &str) -> Result<()> { loop { - // Get notify handle before checking status - let notify = { - match migration_states.0.read().await.get(db_key) { - // No migrations registered for this database - None => return Ok(()), - - Some(state) => match &state.status { - // Migrations completed successfully - MigrationStatus::Complete => return Ok(()), - - // Migrations failed - return the error - MigrationStatus::Failed(error) => { - return Err(Error::Migration(sqlx::migrate::MigrateError::Source( - error.clone().into(), - ))); - } + let states = migration_states.0.read().await; + let notified = match states.get(db_key) { + // No migrations registered for this database + None => return Ok(()), + + Some(state) => match &state.status { + // Migrations completed successfully + MigrationStatus::Complete => return Ok(()), + + // Migrations failed - return the error + MigrationStatus::Failed(error) => { + return Err(Error::Migration(sqlx::migrate::MigrateError::Source( + error.clone().into(), + ))); + } - // Migrations still pending or running - wait for notification - MigrationStatus::Pending | MigrationStatus::Running => state.notify.clone(), - }, - } + // `notify_waiters` stores no permit. Enable the waiter before the + // read guard drops, or a status change in that gap is lost. + MigrationStatus::Pending | MigrationStatus::Running => { + state.notify.clone().notified_owned() + } + }, }; + let mut notified = std::pin::pin!(notified); + notified.as_mut().enable(); + drop(states); + notified.await; + } +} + +/// `true` when this key's migration status is `Pending` or `Running`. +/// +/// `None`, `Complete`, and `Failed` are not in progress. +async fn migration_is_unsettled(migration_states: &MigrationStates, db_key: &str) -> bool { + let states = migration_states.0.read().await; + matches!( + states.get(db_key).map(|state| &state.status), + Some(MigrationStatus::Pending | MigrationStatus::Running) + ) +} - // Wait for migration state change - notify.notified().await; +/// Wait until this key's migration task has left `Pending` and `Running`. +/// +/// `Failed` and `Complete` both count as settled. [`DatabaseRegistration::register_database`] +/// with [`IfExists::Replace`] uses this so a failed migration still allows the key to +/// move to another path. +/// [`await_migrations`] is what turns `Failed` into an error for open calls. +async fn wait_until_migration_settled(migration_states: &MigrationStates, db_key: &str) { + loop { + let states = migration_states.0.read().await; + let notified = match states.get(db_key) { + None => return, + Some(state) => match &state.status { + MigrationStatus::Complete | MigrationStatus::Failed(_) => return, + // `notify_waiters` stores no permit. Enable the waiter before the + // read guard drops, or a status change in that gap is lost. + MigrationStatus::Pending | MigrationStatus::Running => { + state.notify.clone().notified_owned() + } + }, + }; + let mut notified = std::pin::pin!(notified); + notified.as_mut().enable(); + drop(states); + notified.await; } } @@ -1390,13 +1678,15 @@ async fn close_all_loaded_databases_inner( /// Resolve a registered database path by key. /// /// The `db_key` must match a key registered via -/// [`crate::Builder::register_database`] / [`crate::SetupRegistrar::register_database`]. +/// [`crate::Builder::register_database`], [`crate::SetupRegistrar::register_database`], +/// or [`crate::DatabaseRegistration::register_database`]. /// /// Returns `Err(Error::PathNotRegistered)` if the key is not registered. -fn resolve_database_path(db_key: &str, app: &AppHandle) -> Result { +async fn resolve_database_path(db_key: &str, app: &AppHandle) -> Result { let registered_databases = app.state::(); + let paths = registered_databases.paths.read().await; - if let Some(path) = registered_databases.database_path_by_key.get(db_key) { + if let Some(path) = paths.get(db_key) { return Ok(path.clone()); } @@ -1412,6 +1702,7 @@ mod tests { use std::collections::HashMap; use tauri::plugin::Plugin; use tauri::test::{MockRuntime, mock_app, mock_builder, mock_context, noop_assets}; + use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; /// Build and initialize the plugin for a single registered database. @@ -1525,12 +1816,22 @@ mod tests { .unwrap() } + fn init_plugin_app() -> tauri::App { + let mut plugin = Builder::::new().build().unwrap(); + let app = mock_app(); + plugin + .initialize(app.handle(), serde_json::Value::default()) + .expect("plugin init should succeed"); + app + } + fn mock_app_with_registrations( database_path_by_key: HashMap, ) -> tauri::App { let app = tauri::test::mock_app(); app.manage(RegisteredDatabases { - database_path_by_key: Arc::new(database_path_by_key), + paths: RwLock::new(database_path_by_key), + registration_lock: Mutex::new(()), }); app.manage(DbInstances::default()); app.manage(MigrationStates::default()); @@ -1655,11 +1956,11 @@ mod tests { .expect("plugin setup should succeed"); assert_eq!( - resolve_database_path("STATIC", app.handle()).unwrap(), + tauri::async_runtime::block_on(resolve_database_path("STATIC", app.handle())).unwrap(), static_db_path ); assert_eq!( - resolve_database_path("MAIN", app.handle()).unwrap(), + tauri::async_runtime::block_on(resolve_database_path("MAIN", app.handle())).unwrap(), main_db_path ); @@ -1828,7 +2129,8 @@ mod tests { database_path_by_key.insert("MAIN".to_string(), second_path.clone()); let app = mock_app_with_registrations(database_path_by_key); - let path = resolve_database_path("MAIN", app.handle()).unwrap(); + let path = + tauri::async_runtime::block_on(resolve_database_path("MAIN", app.handle())).unwrap(); assert_eq!(path, second_path); } @@ -3713,4 +4015,827 @@ mod tests { } }); } + + fn migrator_for_table(table: &str) -> Migrator { + let migrations_dir = tempfile::tempdir().unwrap(); + std::fs::write( + migrations_dir.path().join("20240101000000_init.sql"), + format!("CREATE TABLE IF NOT EXISTS {table} (id INTEGER PRIMARY KEY, val TEXT);"), + ) + .unwrap(); + tauri::async_runtime::block_on(Migrator::new(migrations_dir.path())).unwrap() + } + + struct ClearTestMainMigrationBarrier; + + impl Drop for ClearTestMainMigrationBarrier { + fn drop(&mut self) { + if let Ok(mut g) = TEST_MAIN_MIGRATION_RUNNING_BARRIER.lock() { + *g = None; + } + } + } + + async fn insert_test_val(app: &tauri::App, db_key: &str, val: &str) { + commands::execute( + app.state::(), + db_key.to_string(), + "INSERT INTO test (val) VALUES (?)".to_string(), + vec![serde_json::Value::String(val.to_string())], + None, + ) + .await + .expect("insert should succeed"); + } + + async fn text_column(wrapper: &DatabaseWrapper, sql: &str, column: &str) -> Vec { + wrapper + .fetch_all(sql.into(), vec![]) + .await + .expect("query should succeed") + .into_iter() + .filter_map(|row| { + row.get(column) + .and_then(|value| value.as_str().map(str::to_string)) + }) + .collect() + } + + #[test] + fn test_register_database_after_setup_then_connect() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &db_path, None, IfExists::Error) + .await + .unwrap(); + load_and_create_test_table(&app, "NOTES").await; + insert_test_val(&app, "NOTES", "profile").await; + + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column(&wrapper, "SELECT val FROM test", "val").await, + vec!["profile".to_string()] + ); + }); + } + + #[test] + fn test_register_database_after_setup_rejects_duplicate_key_and_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let other_path = validate::validate_database_path(temp_dir.path().join("other.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", ¬es_path, None, IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("OTHER", &other_path, None, IfExists::Error) + .await + .unwrap(); + load_and_create_test_table(&app, "NOTES").await; + insert_test_val(&app, "NOTES", "kept").await; + + let duplicate_key = app + .handle() + .register_database("NOTES", &other_path, None, IfExists::Error) + .await + .unwrap_err(); + assert!(matches!(duplicate_key, Error::InvalidConfig(_))); + assert!(duplicate_key.to_string().contains("NOTES")); + + let duplicate_path = app + .handle() + .register_database("BACKUP", ¬es_path, None, IfExists::Error) + .await + .unwrap_err(); + assert!(matches!(duplicate_path, Error::InvalidConfig(_))); + assert!(duplicate_path.to_string().contains("NOTES")); + assert!(duplicate_path.to_string().contains("BACKUP")); + + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column(&wrapper, "SELECT val FROM test", "val").await, + vec!["kept".to_string()] + ); + }); + } + + #[test] + fn test_register_database_after_setup_runs_migrations() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let migrator = migrator_for_table("_late_register"); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &db_path, Some(migrator), IfExists::Error) + .await + .unwrap(); + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column( + &wrapper, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_late_register'", + "name", + ) + .await, + vec!["_late_register".to_string()] + ); + }); + } + + #[test] + fn test_register_database_replace_switches_loaded_database() { + let temp_dir = tempfile::tempdir().unwrap(); + let old_path = validate::validate_database_path(temp_dir.path().join("old.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &old_path, None, IfExists::Error) + .await + .unwrap(); + load_and_create_test_table(&app, "NOTES").await; + insert_test_val(&app, "NOTES", "old-profile").await; + + app.handle() + .register_database("NOTES", &new_path, None, IfExists::Replace) + .await + .unwrap(); + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert!( + text_column( + &wrapper, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'test'", + "name", + ) + .await + .is_empty() + ); + + commands::execute( + app.state::(), + "NOTES".to_string(), + "CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)".to_string(), + vec![], + None, + ) + .await + .unwrap(); + insert_test_val(&app, "NOTES", "new-profile").await; + assert_eq!( + text_column(&wrapper, "SELECT val FROM test", "val").await, + vec!["new-profile".to_string()] + ); + + let old_file = DatabaseWrapper::connect(&old_path, None).await.unwrap(); + assert_eq!( + text_column(&old_file, "SELECT val FROM test", "val").await, + vec!["old-profile".to_string()] + ); + old_file.close().await.unwrap(); + }); + } + + #[test] + fn test_register_database_replace_when_not_loaded() { + let temp_dir = tempfile::tempdir().unwrap(); + let old_path = validate::validate_database_path(temp_dir.path().join("old.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &old_path, None, IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("NOTES", &new_path, None, IfExists::Replace) + .await + .unwrap(); + let wrapper = app.handle().connect("NOTES").await.unwrap(); + let rows = text_column(&wrapper, "SELECT 'open' AS n", "n").await; + assert_eq!(rows, vec!["open".to_string()]); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + new_path + ); + }); + } + + #[test] + fn test_register_database_replace_rejects_same_and_foreign_paths() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let other_path = validate::validate_database_path(temp_dir.path().join("other.db")).unwrap(); + let migrator = migrator_for_table("_same_path"); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", ¬es_path, Some(migrator), IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("OTHER", &other_path, None, IfExists::Error) + .await + .unwrap(); + let loaded = app.handle().connect("NOTES").await.unwrap(); + loaded + .execute( + "INSERT INTO _same_path (val) VALUES (?)".into(), + vec![serde_json::Value::String("kept".to_string())], + ) + .await + .unwrap(); + + let same_path = match app + .handle() + .register_database("NOTES", ¬es_path, None, IfExists::Replace) + .await + { + Err(error) => error, + Ok(_) => panic!("same path should fail"), + }; + assert!(matches!(same_path, Error::InvalidConfig(_))); + assert!( + app.state::() + .0 + .read() + .await + .get("NOTES") + .is_some(), + "same-path replace must leave migration state in place" + ); + + let foreign_path = match app + .handle() + .register_database("NOTES", &other_path, None, IfExists::Replace) + .await + { + Err(error) => error, + Ok(_) => panic!("path owned by another key should fail"), + }; + assert!(matches!(foreign_path, Error::InvalidConfig(_))); + assert!(foreign_path.to_string().contains("OTHER")); + + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column(&wrapper, "SELECT val FROM _same_path", "val").await, + vec!["kept".to_string()] + ); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + notes_path + ); + }); + } + + #[test] + fn test_register_database_replace_runs_migrator_on_new_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let old_path = validate::validate_database_path(temp_dir.path().join("old.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let migrator = migrator_for_table("_retarget_migrate"); + let replacement = migrator_for_table("_retarget_migrate"); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &old_path, Some(migrator), IfExists::Error) + .await + .unwrap(); + let loaded = app.handle().connect("NOTES").await.unwrap(); + loaded + .execute( + "INSERT INTO _retarget_migrate (val) VALUES (?)".into(), + vec![serde_json::Value::String("old-profile".to_string())], + ) + .await + .unwrap(); + + app.handle() + .register_database("NOTES", &new_path, Some(replacement), IfExists::Replace) + .await + .unwrap(); + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column( + &wrapper, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_retarget_migrate'", + "name", + ) + .await, + vec!["_retarget_migrate".to_string()] + ); + assert!( + text_column(&wrapper, "SELECT val FROM _retarget_migrate", "val") + .await + .is_empty() + ); + + let old_file = DatabaseWrapper::connect(&old_path, None).await.unwrap(); + assert_eq!( + text_column(&old_file, "SELECT val FROM _retarget_migrate", "val").await, + vec!["old-profile".to_string()] + ); + old_file.close().await.unwrap(); + }); + } + + #[test] + fn test_register_database_replace_clears_observers_and_subscriptions() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let other_path = validate::validate_database_path(temp_dir.path().join("other.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", ¬es_path, None, IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("OTHER", &other_path, None, IfExists::Error) + .await + .unwrap(); + load_and_create_test_table(&app, "NOTES").await; + connect_to_database(app.handle(), "OTHER", None) + .await + .expect("OTHER should load"); + + let notes_window = + tauri::WebviewWindowBuilder::new(&app, "window-notes", Default::default()) + .build() + .expect("notes window should build"); + let other_window = + tauri::WebviewWindowBuilder::new(&app, "window-other", Default::default()) + .build() + .expect("other window should build"); + + commands::observe( + app.state::(), + app.state::(), + notes_window.as_ref().clone(), + "NOTES".to_string(), + vec!["test".to_string()], + None, + ) + .await + .expect("NOTES observe should succeed"); + + let (tx, _rx) = std::sync::mpsc::channel::(); + let channel = tauri::ipc::Channel::new(move |body| { + let value: serde_json::Value = body + .deserialize() + .expect("payload should deserialize as JSON"); + tx.send(value).ok(); + Ok(()) + }); + commands::subscribe( + app.state::(), + app.state::(), + app.state::(), + notes_window.as_ref().clone(), + "NOTES".to_string(), + vec!["test".to_string()], + channel, + ) + .await + .expect("NOTES subscribe should succeed"); + + commands::observe( + app.state::(), + app.state::(), + other_window.as_ref().clone(), + "OTHER".to_string(), + vec!["test".to_string()], + None, + ) + .await + .expect("OTHER observe should succeed"); + + assert_eq!( + app.state::() + .count_for_db("NOTES") + .await, + 1 + ); + assert_eq!( + app.state::() + .count_for_db("NOTES") + .await, + 1 + ); + assert_eq!( + app.state::() + .count_for_db("OTHER") + .await, + 1 + ); + + app.handle() + .register_database("NOTES", &new_path, None, IfExists::Replace) + .await + .unwrap(); + + assert_eq!( + app.state::() + .count_for_db("NOTES") + .await, + 0, + "replace should abort NOTES subscriptions" + ); + assert_eq!( + app.state::() + .count_for_db("NOTES") + .await, + 0, + "replace should clear NOTES observers" + ); + assert_eq!( + app.state::() + .count_for_db("OTHER") + .await, + 1, + "replace should leave OTHER observers in place" + ); + + let not_loaded = commands::execute( + app.state::(), + "NOTES".to_string(), + "CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)".to_string(), + vec![], + None, + ) + .await + .expect_err("execute before connect should fail"); + assert!(matches!(not_loaded, Error::DatabaseNotLoaded(_))); + + app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + new_path + ); + + commands::execute( + app.state::(), + "NOTES".to_string(), + "CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)".to_string(), + vec![], + None, + ) + .await + .expect("execute after connect should succeed"); + }); + } + + #[test] + fn test_register_database_replace_after_failed_migration() { + let temp_dir = tempfile::tempdir().unwrap(); + let old_path = validate::validate_database_path(temp_dir.path().join("old.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let migrations_dir = tempfile::tempdir().unwrap(); + std::fs::write( + migrations_dir.path().join("20240101000000_init.sql"), + "NOT VALID SQL;", + ) + .unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + let migrator = Migrator::new(migrations_dir.path()).await.unwrap(); + app.handle() + .register_database("NOTES", &old_path, Some(migrator), IfExists::Error) + .await + .unwrap(); + + let err = match app.handle().connect("NOTES").await { + Err(error) => error, + Ok(_) => panic!("connect should fail after a bad migration"), + }; + assert!(matches!(err, Error::Migration(_))); + + let status = app + .state::() + .0 + .read() + .await + .get("NOTES") + .expect("failed migration should leave state") + .status + .clone(); + assert!(matches!(status, MigrationStatus::Failed(_))); + + app.handle() + .register_database("NOTES", &new_path, None, IfExists::Replace) + .await + .unwrap(); + + assert!( + app.state::() + .0 + .read() + .await + .get("NOTES") + .is_none(), + "replace with no migrator should clear migration state" + ); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + new_path + ); + app.handle() + .connect("NOTES") + .await + .expect("connect after replace should succeed"); + }); + } + + #[test] + fn test_register_database_replace_waits_while_migration_pending() { + let temp_dir = tempfile::tempdir().unwrap(); + let old_path = validate::validate_database_path(temp_dir.path().join("old.db")).unwrap(); + let new_path = validate::validate_database_path(temp_dir.path().join("new.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", &old_path, None, IfExists::Error) + .await + .unwrap(); + app.state::() + .0 + .write() + .await + .insert("NOTES".to_string(), MigrationState::new()); + + let app_handle = app.handle().clone(); + let replace_path = new_path.clone(); + let replace_task = tokio::spawn(async move { + app_handle + .register_database("NOTES", replace_path, None, IfExists::Replace) + .await + }); + + // An early return on Pending finishes this task during the wait. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(250); + while std::time::Instant::now() < deadline { + assert!( + !replace_task.is_finished(), + "replace finished while migration was still pending" + ); + tokio::task::yield_now().await; + } + + app.state::() + .0 + .write() + .await + .get_mut("NOTES") + .expect("pending migration state") + .update_status(MigrationStatus::Failed( + "settled so replace can continue".to_string(), + )); + + replace_task + .await + .expect("replace task should not panic") + .expect("replace should succeed after migration settles"); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + new_path + ); + }); + } + + #[test] + fn test_register_database_replace_inserts_unregistered_key() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("NOTES", ¬es_path, None, IfExists::Replace) + .await + .unwrap(); + let wrapper = app.handle().connect("NOTES").await.unwrap(); + assert_eq!( + text_column(&wrapper, "SELECT 'open' AS n", "n").await, + vec!["open".to_string()] + ); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + notes_path + ); + }); + } + + #[test] + fn test_register_database_replace_same_path_after_failed_migration() { + let temp_dir = tempfile::tempdir().unwrap(); + let notes_path = validate::validate_database_path(temp_dir.path().join("notes.db")).unwrap(); + let migrations_dir = tempfile::tempdir().unwrap(); + std::fs::write( + migrations_dir.path().join("20240101000000_init.sql"), + "NOT VALID SQL;", + ) + .unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + let migrator = Migrator::new(migrations_dir.path()).await.unwrap(); + app.handle() + .register_database("NOTES", ¬es_path, Some(migrator), IfExists::Error) + .await + .unwrap(); + + let connect_error = match app.handle().connect("NOTES").await { + Err(error) => error, + Ok(_) => panic!("connect should fail after a bad migration"), + }; + assert!(matches!(connect_error, Error::Migration(_))); + + let same_path = match app + .handle() + .register_database("NOTES", ¬es_path, None, IfExists::Replace) + .await + { + Err(error) => error, + Ok(_) => panic!("same path after Failed should fail"), + }; + assert!(matches!(same_path, Error::InvalidConfig(_))); + assert!( + matches!( + app.state::() + .0 + .read() + .await + .get("NOTES") + .expect("failed migration should leave state") + .status, + MigrationStatus::Failed(_) + ), + "same-path replace must leave the failed migration state in place" + ); + assert_eq!( + resolve_database_path("NOTES", app.handle()).await.unwrap(), + notes_path + ); + }); + } + + #[test] + fn test_register_database_replace_wait_does_not_stall_other_connect() { + let temp_dir = tempfile::tempdir().unwrap(); + let main_path = validate::validate_database_path(temp_dir.path().join("main.db")).unwrap(); + let settings_path = + validate::validate_database_path(temp_dir.path().join("settings.db")).unwrap(); + let new_main = + validate::validate_database_path(temp_dir.path().join("main-next.db")).unwrap(); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + app.handle() + .register_database("MAIN", &main_path, None, IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("SETTINGS", &settings_path, None, IfExists::Error) + .await + .unwrap(); + app.handle().connect("SETTINGS").await.unwrap(); + + app.state::() + .0 + .write() + .await + .insert("MAIN".to_string(), MigrationState::new()); + + let app_handle = app.handle().clone(); + let replace_path = new_main.clone(); + let replace_task = tokio::spawn(async move { + app_handle + .register_database("MAIN", replace_path, None, IfExists::Replace) + .await + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(250); + while std::time::Instant::now() < deadline { + assert!( + !replace_task.is_finished(), + "replace finished while MAIN migration was still pending" + ); + tokio::task::yield_now().await; + } + + tokio::time::timeout( + std::time::Duration::from_millis(500), + app.handle().connect("SETTINGS"), + ) + .await + .expect("SETTINGS connect timed out behind a MAIN replace wait") + .expect("SETTINGS connect should succeed while MAIN replace waits"); + assert!( + !replace_task.is_finished(), + "SETTINGS connect should return before the MAIN replace settles" + ); + + app.state::() + .0 + .write() + .await + .get_mut("MAIN") + .expect("pending migration state") + .update_status(MigrationStatus::Failed( + "settled so replace can continue".to_string(), + )); + + replace_task + .await + .expect("replace task should not panic") + .expect("replace should succeed after migration settles"); + assert_eq!( + resolve_database_path("MAIN", app.handle()).await.unwrap(), + new_main + ); + }); + } + + /// While MAIN migration blocks at [`MigrationStatus::Running`], `connect` must still + /// release `registration_lock` for that wait (via `await_migrations` outside the loop + /// body holding the mutex). Otherwise SETTINGS `connect` blocks behind the global lock. + #[test] + fn test_connect_while_migration_running_does_not_stall_other_connect() { + let temp_dir = tempfile::tempdir().unwrap(); + let main_path = validate::validate_database_path(temp_dir.path().join("main.db")).unwrap(); + let settings_path = + validate::validate_database_path(temp_dir.path().join("settings.db")).unwrap(); + let migrator_main = migrator_for_table("_stall_main"); + let app = init_plugin_app(); + + tauri::async_runtime::block_on(async { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *TEST_MAIN_MIGRATION_RUNNING_BARRIER + .lock() + .expect("test migration barrier lock") = Some(barrier.clone()); + let _clear_barrier = ClearTestMainMigrationBarrier; + + app.handle() + .register_database("MAIN", &main_path, Some(migrator_main), IfExists::Error) + .await + .unwrap(); + app.handle() + .register_database("SETTINGS", &settings_path, None, IfExists::Error) + .await + .unwrap(); + + let app_h = app.handle().clone(); + let connect_main = tokio::spawn(async move { app_h.connect("MAIN").await }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let migration_states = app.state::(); + loop { + let states = migration_states.0.read().await; + if matches!( + states.get("MAIN").map(|s| &s.status), + Some(MigrationStatus::Running) + ) { + break; + } + drop(states); + assert!( + std::time::Instant::now() < deadline, + "MAIN migration never reached Running" + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + + tokio::time::timeout( + std::time::Duration::from_millis(500), + app.handle().connect("SETTINGS"), + ) + .await + .expect("SETTINGS connect timed out while MAIN migration was running") + .expect("SETTINGS connect should succeed while MAIN migration blocks"); + + barrier.wait().await; + + connect_main + .await + .expect("connect MAIN task should not panic") + .expect("connect MAIN should complete after barrier release"); + }); + } }