diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f75736f6..4416416f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: run: npm run verify:ios - name: Build Android run: npm run verify:android + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium - name: Build Web run: npm run verify:web - name: Build Electron diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c678f6..e1982c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [8.2.0](https://github.com/capacitor-community/sqlite/compare/v8.1.0...v8.2.0) (2026-08-01) + + +### ⚠ BREAKING CHANGES + +* **web:** the web implementation no longer uses `jeep-sqlite`. Remove the `jeep-sqlite` dependency, the `defineCustomElements`/`applyPolyfills` import, the `` element and any React JSX augmentation for it from your app. `initWebStore()` is still mandatory and is still called the same way. +* **web:** the `sql-wasm.wasm` copy step is gone. The worker and `sqlite3.wasm` ship inside the package under `dist/`; delete any `copysqlwasm` script or assets entry. +* **web:** minimum browser versions are now Chrome/Android WebView 80, Safari 14 and Firefox 74. Below that the plugin fails to load rather than degrading, and no build target in your app changes that. +* **web:** the `` `autosave` attribute no longer exists. `saveToStore()` is a no-op when the store is on OPFS and the real image flush on the IndexedDB fallback. +* **web:** integer values above 2^53 are returned as `BigInt` instead of a silently truncated number. `JSON.stringify` throws on those; `exportToJson` encodes them as decimal strings. +* **web:** only one tab per origin may own the store. A second tab now gets an explicit error from `initWebStore()` instead of racing the first. +* **web:** foreign keys are now enforced. `PRAGMA foreign_keys` is set ON at open, as it already was on every other platform, so a schema that was quietly violating its own constraints will start reporting them. + +### Features + +* **web:** replace the jeep-sqlite pass-through with an in-repo engine on `@sqlite.org/sqlite-wasm`, running in a dedicated worker, with databases stored as real files in OPFS through the `opfs-sahpool` VFS and no COOP/COEP headers required +* **web:** add an automatic `:memory:` + IndexedDB fallback tier for browsers without OPFS sync access handles, so the two tiers differ only in where the bytes rest +* **web:** import databases from the previous `jeep-sqlite` IndexedDB store once, on the first `initWebStore()`, verifying each one with `PRAGMA integrity_check` before retiring the legacy store +* **web:** support read-only connections, which were previously unsupported on this platform +* **web:** add a real browser test suite (vitest browser mode, Playwright Chromium) covering both durability tiers +* **web:** export `setSqliteWorkerFactory`, `setSqliteWebOptions` and `setSqliteLocalDiskAdapter` for bundlers and file pickers that need to override the defaults +* **web:** propagate a soft delete along foreign keys, applying each constraint's `ON DELETE` action to the `sql_deleted` marking, recursively and for every referencing table +* **web:** move databases out of the IndexedDB fallback store and into OPFS automatically when a browser gains OPFS support, so an engine update no longer strands them +* **web:** close and pause the store when a Capacitor app is backgrounded and restore it on return, with a worker restart as the fallback when the gentle path cannot run + +### Bug Fixes + +* **web:** a failed database upgrade now restores the pre-upgrade image and rejects, instead of returning a working connection at the old version with no signal that the migration failed +* **web:** errors keep their message instead of being re-thrown as the string `Error: Error: ...` +* **web:** a worker that fails to load now says which of the two causes it was, an unsupported browser or an asset the bundler did not serve, and quotes what the browser reported +* **web:** a `DELETE` inside an `execute()` batch is now recorded on a sync-tracked database instead of physically removing the rows, matching `run()` and every other platform +* **web:** a soft delete whose `WHERE` clause contained a string literal produced SQL that did not parse; the clause now keeps its literals +* **web:** `DELETE ... RETURNING` on a sync-tracked database produced SQL that did not parse; the clause is now carried to the rewritten statement and the rows come back +* **web:** a `DELETE` against a table without a `sql_deleted` column is no longer rewritten just because some other table in the database has one +* **web:** a `DELETE` against a double-quoted table name on a sync-tracked database performed a real delete instead of recording one +* **web:** a `DELETE` whose `WHERE` clause contained a top-level `OR` re-marked already-deleted rows, because `AND` binds tighter than `OR` in the rewritten statement +* **web:** a table whose name is a reserved word, such as `order`, switched soft deletes off for the whole database +* **web:** `CREATE TABLE` issued through `run()` rather than `execute()` left the cached schema stale, so a foreign key added that way was invisible to the next delete +* **web:** `importFromJson` with `overwrite` left the caller's open connection closed behind its back + ## [8.1.0](https://github.com/capacitor-community/sqlite/compare/v8.0.1...v8.1.0) (2026-03-30) diff --git a/README.md b/README.md index 42e2ed90..08341710 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@

- Capacitor community plugin for Native and Electron SQLite Databases. + Capacitor community plugin for Native, Electron and Web SQLite Databases. - In Native, databases could be encrypted with `SQLCipher` - In Electron, databases could be encrypted with `better-sqlite3-multiple-ciphers` + - In Web, databases run on `@sqlite.org/sqlite-wasm` in a Worker and persist in OPFS; encryption is not available


@@ -45,8 +46,6 @@ npx cap sync ``` pnpm install --save @capacitor-community/sqlite -pnpm install --save jeep-sqlite -pnpm install --save sql.js npx cap sync ``` @@ -101,35 +100,74 @@ export default config; ## Tutorials Blog - - [JeepQ Capacitor Plugin Tutorials](https://jepiqueau.github.io/) + - [JeepQ Capacitor Plugin Tutorials](https://jepiqueau.github.io/) (the Web tutorials there describe the previous `jeep-sqlite` setup, which no longer applies) ## Web Quirks -The plugin follows the guidelines from the `Capacitor Team`, +On the Web platform the plugin runs [`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm), the official SQLite build, inside a dedicated Worker that it creates itself. There is nothing to install alongside it and nothing to copy into your assets folder: the worker (`dist/web-worker.js`) and the SQLite binary (`dist/sqlite3.wasm`) ship inside the package. Full setup is in [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md), which is required reading for the Web platform. -- [Capacitor Browser Support](https://capacitorjs.com/docs/v3/web#browser-support) +`initWebStore()` is still mandatory and is still called exactly as before, once, before the first connection. -Meaning that it will not work in IE11 without additional JavaScript transformations, e.g. with [Babel](https://babeljs.io/). -You'll need the usual capacitor/android/react npm script to build and copy the assets folder. +#### Where the data lives -#### For Angular framework +`initWebStore()` picks one of two durability tiers: -- Copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `src/assets` folder of YOUR_APP +| Tier | Storage | When it is used | +| ---- | ------- | --------------- | +| 1 | Real database files in the `Origin Private File System`, through the `opfs-sahpool` VFS. Needs no COOP/COEP headers and no `SharedArrayBuffer`, so it works inside Capacitor WebViews and on ordinary hosting. | Whenever the browser has OPFS sync access handles. | +| 2 | `:memory:` databases whose whole-file image is written to IndexedDB, the model the previous implementation used. | Automatic fallback when it does not. | -#### For Vue & React frameworks +`saveToStore()` is a no-op on tier 1, where every committed write is already durable, and performs the real image flush on tier 2 (as do `close` and `closeConnection`). Calling it unconditionally is the portable pattern. The old `` `autosave` attribute is gone and has no replacement. -- Copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +#### Browser support -## Web Debugging Tools +There are two separate floors, and they mean different things: + +| Floor | Chromium / Android WebView | Safari / iOS WebKit | Firefox | What happens below it | +| ----- | -------------------------- | ------------------- | ------- | --------------------- | +| Engine (`BigInt`, optional chaining, nullish coalescing) | 80 | 14 | 74 | **The plugin does not load at all.** The failure is a syntax error inside the SQLite build, not a fallback, and no transpiler setting in your app can change it. | +| Durability (OPFS sync access handles) | 108 | 16.4 | 111 | Tier 2 above: everything works, the database is an image in IndexedDB rather than a file. Android WebView reached this in M132, January 2025. | + +#### Who actually lands on tier 2 + +Tier 2 is chosen at `initWebStore()` when the browser lacks OPFS sync access handles: +concretely, `navigator.storage.getDirectory` or +`FileSystemFileHandle.prototype.createSyncAccessHandle` is missing, or the SQLite build's own +API version check rejects. In browser versions that means Safari / iOS WebKit 14 to 16.3, +Chromium 80 to 107, Firefox 74 to 110, and Android System WebView before M132 (January 2025). +Two version-independent cases: Safari private browsing always runs tier 2 (OPFS is unavailable +there on every Safari version), and a store already owned by another tab is an explicit error, +never a silent drop to tier 2. + +How much traffic that is: measured against caniuse-lite 1.0.30001806 (StatCounter data, +checked 2026-08), the whole tier 2 version band is about **1.4% of global browser usage**: +Safari / iOS 14-16.3 at ~0.5%, Chromium 80-107 remnants at ~0.9%, Firefox 74-110 at ~0.01%. +Put differently, of the usage that can load this plugin at all, **roughly 98% runs tier 1 and +under 2% lands on tier 2**, and the band shrinks as evergreen browsers update. In-app WebView +versions are not visible to StatCounter, so treat WebViews older than M132 (devices without +Google Play services, or with WebView updates disabled) as a small qualitative extra on top. -When using the Web platform, SQLite databases are backed by `jeep-sqlite` and stored in IndexedDB, which can be difficult to inspect during development. +Why the tier stays despite the small share: Capacitor 8 itself supports iOS 15.0+ and Android +API 24+ with WebViews as old as Chrome 60, so the platform's official support envelope reaches +below the tier 1 floor, and a Capacitor plugin should not fail where Capacitor does not; and +Safari private browsing is tier 2 forever regardless of version, so the tier is the difference +between an app that works ephemerally in a private tab and one that is simply broken there. -For easier debugging, there is a Chrome DevTools extension that allows browsing, querying, and exporting `jeep-sqlite` databases directly from IndexedDB: +#### Other things worth knowing -- **Jeep SQLite Browser (Chrome DevTools Extension)** - - Chrome Web Store: https://chromewebstore.google.com/detail/jeep-sqlite-browser/ocgeealadeabmhponndjebghfkbfbnch - - GitHub: https://github.com/pinguluk/jeep-sqlite-browser +- **One tab at a time.** OPFS access handles are single-owner by design, so `initWebStore()` fails with an explicit error if another tab of the same origin already owns the store. +- **Migration is automatic.** The first `initWebStore()` after upgrading imports every database left behind by the previous `jeep-sqlite`/IndexedDB implementation, verifies each one with `PRAGMA integrity_check`, and only then retires the old store. A failure leaves the old data untouched and warns on the console. +- **Tier 2 databases follow you up to tier 1.** When a browser that lacked OPFS gains it, which is the normal upgrade path for the devices that run on tier 2, `initWebStore()` moves the stored images into OPFS and only then removes them. +- **Foreign keys are enforced**, as on every other platform. On a database that participates in sync, a soft delete also applies each constraint's `ON DELETE` action to the children, and to their children. +- **Backgrounding is handled** under Capacitor native: the store is closed and paused before the OS suspends the app, and reopened on return. `pauseWebStore()`, `resumeWebStore()` and `restartWebStore()` are there if you would rather drive it from `App.appStateChange`. +- **Integers above 2^53 are returned as `BigInt`.** The previous web engine silently lost precision on those. `JSON.stringify` refuses to serialise a `BigInt`, so code that stringifies query results may need `exportToJson`, which handles this, or a replacer. +- **No encryption.** There is no SQLCipher build for wasm, so encrypted connections and every secret-related method still reject on Web. +- **Read-only connections work on Web**, on both tiers. + +## Web Debugging Tools + +Where to look depends on the tier. On tier 1, open DevTools > Application > Storage and browse the Origin Private File System: the databases sit in the `.capacitor-sqlite` directory, though `opfs-sahpool` names the files opaquely, so exporting through the plugin is usually easier than reading them in place. On tier 2, they appear in IndexedDB under `capacitor-sqlite-store` > `databases`, one whole-file image per key. ## Android Quirks @@ -218,11 +256,11 @@ npm install --save-dev electron-builder@24.6.4 | Name | Android | iOS | Electron | Web | | :--------------------------- | :------ | :--- | :------- | :--- | | createConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| createConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| createConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | closeConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| closeConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| closeConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | isConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| isConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| isConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | open (non-encrypted DB) | ✅ | ✅ | ✅ | ✅ | | open (encrypted DB) | ✅ | ✅ | ✅ | ❌ | | close | ✅ | ✅ | ✅ | ✅ | @@ -259,7 +297,7 @@ npm install --save-dev electron-builder@24.6.4 | clearEncryptionSecret | ✅ | ✅ | ✅ | ❌ | | checkEncryptionSecret | ✅ | ✅ | ✅ | ❌ | | initWebStore | ❌ | ❌ | ❌ | ✅ | -| saveToStore | ❌ | ❌ | ❌ | ✅ | +| saveToStore | ❌ | ❌ | ❌ | ✅ | Web: no-op on OPFS, flushes the image on the IndexedDB tier | | getNCDatabasePath | ✅ | ✅ | ❌ | ❌ | | createNCConnection | ✅ | ✅ | ❌ | ❌ | | closeNCConnection | ✅ | ✅ | ❌ | ❌ | @@ -295,7 +333,7 @@ npm install --save-dev electron-builder@24.6.4 - [TypeORM-From-5.6.0](https://github.com/capacitor-community/sqlite/blob/master/docs/TypeORM-Usage-From-5.6.0.md) -- [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) +- [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) (required reading for the Web platform) - [Non Conformed Databases](https://github.com/capacitor-community/sqlite/blob/master/docs/NonConformedDatabases.md) @@ -310,6 +348,11 @@ npm install --save-dev electron-builder@24.6.4 ## Applications demonstrating the use of the plugin and related documentation +> The sample apps below predate the current Web engine. Their native code is unaffected, but every +> Web setup step they show (installing `jeep-sqlite`, defining the custom element, copying +> `sql-wasm.wasm`) has been removed from the plugin. Follow +> [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) instead. + ### Ionic/Angular - [Web ionic7-angular-sqlite-app](https://github.com/jepiqueau/blog-tutorials-apps/tree/main/SQLite/Part-1/ionic7-angular-sqlite-app) Ionic 7 Angular 16 Capacitor 5 SQLite CRUD operations for Web. @@ -370,7 +413,7 @@ npm install --save-dev electron-builder@24.6.4 The iOS and Android codes are using `SQLCipher` allowing for database encryption. The iOS code is using `ZIPFoundation` for unzipping assets files The Electron code is using `better-sqlite3-multiple-ciphers` , `electron-json-storage` and `node-fetch` from 5.0.4. -The Web code is using the Stencil component `jeep-sqlite` based on `sql.js`, `localforage`. and `jszip` +The Web code is using `@sqlite.org/sqlite-wasm`, the official SQLite wasm build, in a dedicated Worker, with `fflate` for unzipping assets files. ## Contributors ✨ diff --git a/docs/API.md b/docs/API.md index 25e3a03c..a680de7b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -87,7 +87,12 @@ The plugin add a suffix "SQLite" and an extension ".db" to the database name giv ### Web -- the database is stored in Web browser INDEXEDDB storage as a `localforage` store under the `jeepSqliteStore` name and `databases` table name. +Where a database is stored depends on the durability tier the plugin selected when `initWebStore()` ran: + +- Tier 1: as a real file in the browser's `Origin Private File System`, in the pool directory `.capacitor-sqlite`, through the `opfs-sahpool` VFS. This is the normal case. +- Tier 2 (fallback for browsers without OPFS sync access handles): as a whole-database image in `IndexedDB`, in a database named `capacitor-sqlite-store` under the object store `databases`. + +In both cases the file name is `YOUR_DATABASE_NAMESQLite.db`, the same convention the native platforms use. See [Web Usage](Web-Usage.md) for the browser requirements behind that choice. ## Comments within SQL statements @@ -164,7 +169,7 @@ Unexpected or erroneous behaviour users of this library have encountered. In https://github.com/capacitor-community/sqlite/issues/393 a user of this library experienced bugs when running a statement that itself contained multiple update statements. -The statement executed fine on the web version of this library (sql-wasm.wasm). +The statement executed fine on the web version of this library. But on android and IOS only some updates took place, some updates were ignored and did not take effect in the database. @@ -176,12 +181,14 @@ Note that in general in SQLite this is not recommended, since it makes your quer ## Write-Ahead Logging (WAL) - - Electron, Web platforms only WAL journal_mode is implemented + - Electron platform: only WAL journal_mode is implemented - Both WAL and WAL2 journal_mode are implemented - Android WAL2 is set by default, so you do not need to set it up + - Web platform: WAL is NOT available. The `opfs-sahpool` VFS has no shared-memory support, so tier 1 databases stay on the `delete` journal and a `PRAGMA journal_mode=WAL` is silently refused; tier 2 databases run in memory and use the `memory` journal. + ## Error Return values - For all methods, a message containing the error message will be returned @@ -276,6 +283,12 @@ initWebStore() => Promise Initialize the web store +Mandatory on the web platform and must resolve before the first `createConnection`. It starts +the plugin's SQLite worker, selects the durability tier (databases as files in OPFS, or as +whole-file images in IndexedDB on browsers without OPFS sync access handles), and on its very +first run imports any database left behind by the previous jeep-sqlite implementation. +Rejects when another tab of the same origin already owns the store. + **Since:** 3.2.3-1 -------------------- @@ -287,7 +300,11 @@ Initialize the web store saveToStore(options: capSQLiteOptions) => Promise ``` -Save database to the web store +Save database to the web store + +A no-op when the web store is on OPFS, where every committed write is already durable, and +the real flush of the database image to IndexedDB on the fallback tier. Safe and cheap to +call unconditionally after a batch of writes. | Param | Type | Description | | ------------- | ------------------------------------------------------------- | -------------------------------------------------- | diff --git a/docs/APIConnection.md b/docs/APIConnection.md index 422dd953..3992ff3f 100644 --- a/docs/APIConnection.md +++ b/docs/APIConnection.md @@ -64,6 +64,11 @@ initWebStore() => Promise Init the web store +Mandatory on the web platform and must resolve before the first `createConnection`. It starts +the plugin's SQLite worker, selects the durability tier, and on its very first run imports any +database left behind by the previous jeep-sqlite implementation. Rejects when another tab of +the same origin already owns the store. + **Since:** 3.2.3-1 -------------------- @@ -75,7 +80,10 @@ Init the web store saveToStore(database: string) => Promise ``` -Save the datbase to the web store +Save the database to the web store + +A no-op when the web store is on OPFS, and the real flush of the database image to IndexedDB +on the fallback tier. Safe and cheap to call unconditionally. | Param | Type | | -------------- | ------------------- | @@ -247,13 +255,13 @@ createConnection(database: string, encrypted: boolean, mode: string, version: nu Create a connection to a database -| Param | Type | -| --------------- | -------------------- | -| **`database`** | string | -| **`encrypted`** | boolean | -| **`mode`** | string | -| **`version`** | number | -| **`readonly`** | boolean | +| Param | Type | Description | +| --------------- | -------------------- | --------------------------------------------- | +| **`database`** | string | | +| **`encrypted`** | boolean | not available on the web platform | +| **`mode`** | string | | +| **`version`** | number | | +| **`readonly`** | boolean | supported on every platform, the web included | **Returns:** Promise<SQLiteDBConnection> diff --git a/docs/Ionic-Angular-Usage.md b/docs/Ionic-Angular-Usage.md index 359c9452..71217ee4 100644 --- a/docs/Ionic-Angular-Usage.md +++ b/docs/Ionic-Angular-Usage.md @@ -50,6 +50,15 @@ export class SQLiteService { } this.sqlitePlugin = CapacitorSQLite; this.sqlite = new SQLiteConnection(this.sqlitePlugin); + if ( this.platform === 'web' ) { + // Required on web, and the only web-specific step there is: it boots the plugin's + // worker, selects the durability tier (OPFS when the browser supports it, whole + // database images in IndexedDB otherwise), and on its very first run imports any + // database left behind by the previous jeep-sqlite based implementation. It must + // complete before any createConnection call. There is no element to add to the DOM + // and no wasm file to copy into the app's assets. + await this.sqlite.initWebStore(); + } this.isService = true; return true; } @@ -165,12 +174,13 @@ export class SQLiteService { * @param encrypted * @param mode * @param version + * @param readonly read-only connections work on every platform, web included */ async createConnection(database: string, encrypted: boolean, - mode: string, version: number + mode: string, version: number, readonly = false ): Promise { this.ensureConnectionIsOpen(); - const db: SQLiteDBConnection = await this.sqlite.createConnection(database, encrypted, mode, version, false); + const db: SQLiteDBConnection = await this.sqlite.createConnection(database, encrypted, mode, version, readonly); if ( db == null ) { throw new Error(`no db returned is null`); } @@ -316,7 +326,11 @@ export class SQLiteService { } /** - * Save a database to store + * Save a database to store. + * On the web this flushes the whole database image to IndexedDB, but only on the fallback + * tier: when the plugin is running on OPFS every committed write is already durable and this + * is a no-op. Calling it after a batch of writes is the portable pattern either way. The + * "autosave" attribute it used to pair with no longer exists. * @param database */ async saveToStore(database: string): Promise { diff --git a/docs/Ionic-React-Usage.md b/docs/Ionic-React-Usage.md index 72461924..709a8914 100644 --- a/docs/Ionic-React-Usage.md +++ b/docs/Ionic-React-Usage.md @@ -34,10 +34,14 @@ To install it in your Ionic/React App npm i --save-dev react-sqlite-hook@latest ``` - - `for Web Browser` -```bash - npm i --save-dev jeep-sqlite@latest -``` + - `for Web Browser` + + Nothing further to install. The plugin ships its own worker (`dist/web-worker.js`) and SQLite + build (`dist/sqlite3.wasm`). Note the browser floors: the plugin needs `BigInt`, optional + chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, Firefox 74) or it does + not load at all, and it stores databases in OPFS only where sync access handles exist + (Chromium 108, WebKit 16.4, Firefox 111, Android WebView M132), falling back to IndexedDB + images below that. See [Web Usage](Web-Usage.md). ### React SQLite Hook Declaration for platforms other than Web @@ -86,7 +90,7 @@ export default App; Now the Singleton SQLite Hook `sqlite`and Existing Connections Store `existingConn` can be used in other components ### React SQLite Hook Declaration for platforms including Web -As for the Web platform, the `jeep-sqlite` Stencil component is used and requires the DOM it is then defined and initialized in the `index.tsx` file. +On the Web platform the only extra step is `initWebStore()`, called once before any connection is created. It boots the plugin's worker, selects the durability tier, and on its very first run imports any database left behind by the previous `jeep-sqlite` based implementation. There is no custom element to define, no `applyPolyfills`, and no DOM dependency, so the declaration below no longer has to live in `index.tsx` for the web's sake. ```ts import React from 'react'; @@ -94,36 +98,15 @@ import { createRoot } from 'react-dom/client'; import App from './App'; import * as serviceWorkerRegistration from './serviceWorkerRegistration'; import reportWebVitals from './reportWebVitals'; -import { defineCustomElements as jeepSqlite, applyPolyfills, JSX as LocalJSX } from "jeep-sqlite/loader"; -import { HTMLAttributes } from 'react'; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; -type StencilToReact = { - [P in keyof T]?: T[P] & Omit, 'className'> & { - class?: string; - }; -} ; - -declare global { - export namespace JSX { - interface IntrinsicElements extends StencilToReact { - } - } -} - -applyPolyfills().then(() => { - jeepSqlite(window); -}); window.addEventListener('DOMContentLoaded', async () => { console.log('$$$ in index $$$'); const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) try { if(platform === "web") { - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - await customElements.whenDefined('jeep-sqlite'); await sqlite.initWebStore(); } const ret = await sqlite.checkConnectionsConsistency(); @@ -329,6 +312,8 @@ const Test2dbs: React.FC = () => { // initialize the connection const db = await sqlite .createConnection("testNew", false, "no-encryption", 1); + // Native and Electron only: encryption is not supported on the Web platform, where + // this call rejects. const db1 = await sqlite .createConnection("testSet", true, "secret", 1); @@ -425,6 +410,9 @@ const Test2dbs: React.FC = () => { return false; } if (platform === "web") { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on + // OPFS, where every committed write is already durable. Calling it + // unconditionally is the portable pattern. await sqlite.saveToStore("testNew"); await sqlite.saveToStore("testSet"); } diff --git a/docs/Ionic-Vue-Usage.md b/docs/Ionic-Vue-Usage.md index 4620b971..dc6800db 100644 --- a/docs/Ionic-Vue-Usage.md +++ b/docs/Ionic-Vue-Usage.md @@ -30,6 +30,13 @@ To install it in your Ionic/Vue App npm i --save-dev vue-sqlite-hook@latest ``` +No extra package is needed for the Web platform: the plugin ships its own worker +(`dist/web-worker.js`) and SQLite build (`dist/sqlite3.wasm`). It does have browser floors, +though: `BigInt`, optional chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, +Firefox 74) or it does not load at all, and OPFS sync access handles (Chromium 108, WebKit 16.4, +Firefox 111, Android WebView M132) for durable files rather than the IndexedDB fallback. See +[Web Usage](Web-Usage.md). + ### Vue SQLite Hook Declaration for platforms other than Web To use the `vue-sqlite-hook`as a singleton hook, the declaration must be done in the `main.ts` file of your application @@ -99,21 +106,20 @@ Now the Singleton SQLite Hook `$sqlite`and Existing Connections Store `$existing ### Vue SQLite Hook Declaration for platforms including Web -As for the Web platform, the `jeep-sqlite` Stencil component is used and requires the DOM: -the declaration of the SQLite Hook has to be moved to the App.vue +On the Web platform the only extra step is `initWebStore()`, called once before any connection is +created. It boots the plugin's worker, selects the durability tier, and on its very first run +imports any database left behind by the previous `jeep-sqlite` based implementation. Nothing needs +the DOM any more, so the SQLite Hook declaration can stay in `main.ts` exactly as in the section +above; the App.vue variant below is kept only as an alternative pattern. So the `main.ts`file ```js ... -import { defineCustomElements as jeepSqlite, applyPolyfills } from "jeep-sqlite/loader"; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; import { useState } from '@/composables/state'; import { schemaToImport179 } from '@/utils/utils-import-from-json'; -applyPolyfills().then(() => { - jeepSqlite(window); -}); window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) @@ -138,10 +144,6 @@ window.addEventListener('DOMContentLoaded', async () => { try { if(platform === "web") { - // Create the 'jeep-sqlite' Stencil component - const jeepSqlite = document.createElement('jeep-sqlite'); - document.body.appendChild(jeepSqlite); - await customElements.whenDefined('jeep-sqlite'); // Initialize the Web store await sqlite.initWebStore(); } diff --git a/docs/SQLiteTransaction.md b/docs/SQLiteTransaction.md index 07b78d6b..b4c0ee99 100644 --- a/docs/SQLiteTransaction.md +++ b/docs/SQLiteTransaction.md @@ -10,9 +10,9 @@ ### By Default The three SQLite methods (execute, executeSet, and run) in the `@capacitor-community/sqlite` plugin are inherently transactional. - On the Web platform, the in-memory database is saved in the store after the execution of each method if you enable the autosave property of the jeep-sqlite web component. + On the Web platform, how durable that transaction is depends on the tier the plugin selected when `initWebStore()` ran. On tier 1 (OPFS) a committed transaction is already on disk when the method resolves, exactly as on native. On tier 2 (the `:memory:` + IndexedDB fallback, used when the browser has no OPFS sync access handles) the change lives in memory until the database image is flushed, which happens on `saveToStore`, `close`, `closeConnection`, after `importFromJson`, and after a committed explicit transaction. See [Web Usage](Web-Usage.md) for how to tell which tier you are on. - This approach is suitable and secure when the methods are executed from a UI component. However, it can be notably slow when dealing with a batch of commands. + This approach is suitable and secure when the methods are executed from a UI component. However, it can be notably slow when dealing with a batch of commands, because every call pays for its own implicit transaction. Code example: @@ -95,6 +95,7 @@ // Commit Transaction await db.commitTransaction() if (platform === 'web') { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on OPFS. await sqliteServ.saveToStore(dbName); } setLog(prevLog => prevLog + '### Commit Test Transaction Manage ###\n'); @@ -118,6 +119,8 @@ This method has been updated to utilize the new techniques outlined in the `For Batch of Commands` chapter. It accepts a collection of tasks of type `capTask` as its input parameter. + On the Web platform the whole task set is durable as soon as `executeTransaction` resolves when the plugin is on tier 1 (OPFS). On tier 2, follow it with `saveToStore(dbName)` exactly as in the example above. + ```ts const testExecuteTransaction = async (db: SQLiteDBConnection) => { setLog(prevLog => prevLog + '### Start Execute Transaction ###\n'); diff --git a/docs/SyntaxScanner-For-SQLite-Code.md b/docs/SyntaxScanner-For-SQLite-Code.md index 34df6297..cde26028 100644 --- a/docs/SyntaxScanner-For-SQLite-Code.md +++ b/docs/SyntaxScanner-For-SQLite-Code.md @@ -12,7 +12,7 @@ This was only tested with Intellij Ultimate syntax highlighting that comes when In for example H2-database, with a normal database file you can always connect it to the IDE and get syntax highlighting. This capacitor-community/sqlite plugin however doesn't use a "normal database file" however. -Instead it uses either the IOS/Android emulator or real phone db-file, or if you are using the web version of this plugin then it is using the sql-wasm.wasm file. +Instead it uses either the IOS/Android emulator or real phone db-file, or if you are using the web version of this plugin then the database lives inside the browser, in the Origin Private File System or, on browsers without it, as an image in IndexedDB. All of those database files cannot be read from an IDE like IntelliJ and thus it might give you a lot of errors when looking at your SQLite code snippets in typescript saying things like "table not found" etc. ### The solution: diff --git a/docs/TypeORM-Usage-From-5.6.0.md b/docs/TypeORM-Usage-From-5.6.0.md index be95d59e..aab43af5 100644 --- a/docs/TypeORM-Usage-From-5.6.0.md +++ b/docs/TypeORM-Usage-From-5.6.0.md @@ -13,12 +13,19 @@ - The `typeOrm` package now has a `capacitor` driver type that must be used with the `@capacitor-community/sqlite` capacitor plugin. - - Apps using the `@capacitor-community/sqlite` capacitor plugin cannot use the `CLI` of the `typeOrm` package. Developers trying to do so, will receive a message stating that the "jeep-sqlite element is not present in the DOM". The Web part of the plugin needs the DOM to create and store the database. + - Apps using the `@capacitor-community/sqlite` capacitor plugin cannot use the `CLI` of the `typeOrm` package. The `CLI` runs under Node, and the Web part of the plugin needs a browser: it runs SQLite in a dedicated Worker and stores databases in the Origin Private File System, or in IndexedDB on browsers without it. Neither exists under Node, so the CLI cannot reach a database. - In release 5.6.0, I attempted to propose a workaround by utilizing certain Node.js packages like 'fs', 'os', and 'path' to facilitate the generation of initial and subsequent migration files. While this workaround worked smoothly on some frameworks using Vite (such as React and Svelte), unfortunately, I encountered difficulties with Angular due to the inability to find a suitable method for building the app. - In release 5.6.1.1, I decided to retract the proposal for the workaround and refrain from implementing it. As a consequence, migration files will need to be created manually. + - On the Web platform, four constraints are worth knowing before you start. They are described in full in [Web Usage](Web-Usage.md): + + - The plugin needs a browser with `BigInt`, optional chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, Firefox 74). Below that it does not load at all, and no transpiler target can change that. + - Databases are durable files in the Origin Private File System when the browser supports OPFS sync access handles (Chromium 108, WebKit 16.4, Firefox 111, Android WebView M132), and whole-database images in IndexedDB otherwise. The `saveToStore` calls below matter only in the second case. + - Only one browser tab may own the store. A second tab gets an explicit error from `initWebStore()`. + - Integer values above 2^53 are returned as `BigInt`. Declare such columns as `bigint` in your entities with a transformer rather than `number`, which the previous web engine let you get away with by silently losing precision. + ## Applications/Tutorials @@ -368,12 +375,9 @@ Somewhere in the `main.ts` file of your App you must initialize your DataSources ```ts ... -import { JeepSqlite } from 'jeep-sqlite/dist/components/jeep-sqlite'; import sqliteParams from './databases/sqliteParams'; import authorDataSource from './databases/datasources/AuthorDataSource'; -customElements.define('jeep-sqlite', JeepSqlite); - const initializeDataSources = async () => { //check sqlite connections consistency await sqliteParams.connection.checkConnectionsConsistency() @@ -391,31 +395,28 @@ const initializeDataSources = async () => { await mDataSource.dataSource.runMigrations(); } if( sqliteParams.platform === 'web') { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on OPFS. await sqliteParams.connection.saveToStore(mDataSource.dbName); } } } -if (sqliteParams.platform !== "web") { - initializeDataSources(); +const start = async () => { + if (sqliteParams.platform === "web") { + // Required on web, and the only web-specific step there is. It boots the plugin's worker, + // selects the durability tier, and on its first run imports any database left behind by the + // previous jeep-sqlite based implementation. + await sqliteParams.connection.initWebStore(); + } + await initializeDataSources(); // Now depending on the Framework render your APP ... -} else { - window.addEventListener('DOMContentLoaded', async () => { - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - customElements.whenDefined('jeep-sqlite').then(async () => { - await sqliteParams.connection.initWebStore(); - await initializeDataSources(); - // Now depending on the Framework render your APP - ... - }) - .catch ((err) => { - console.log(`Error: ${err}`); - throw new Error(`Error: ${err}`) - }); - }); +} +start().catch((err) => { + console.log(`Error: ${err}`); + throw new Error(`Error: ${err}`); +}); ``` diff --git a/docs/TypeORM-Usage.md b/docs/TypeORM-Usage.md index 4bafb829..332bb8c0 100644 --- a/docs/TypeORM-Usage.md +++ b/docs/TypeORM-Usage.md @@ -4,6 +4,10 @@ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!! This documentation is obsolete if you are using Ionic7 and Vite !!!! !!!! Go To TypeORM-Usage-From-5.6.0 +!!!! +!!!! Its Web setup also predates the current web engine. All Web setup, +!!!! including initWebStore and asset handling, is documented in +!!!! TypeORM-Usage-From-5.6.0 and in Web-Usage. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ```typescript @@ -220,7 +224,7 @@ module.exports = { "$schema": "https://json.schemastore.org/swcrc", "jsc": { "externalHelpers": true, - "target": "es5", + "target": "es2020", "preserveAllComments": true, "parser": { "syntax": "typescript", @@ -236,6 +240,8 @@ module.exports = { } ``` +The target must stay at `es2020` or later. The web engine depends on `BigInt`, which cannot be downlevelled, and the shipped `@sqlite.org/sqlite-wasm` bundle uses optional chaining and nullish coalescing. Building for `es5` does not widen browser support here; it only breaks the build. + #### 3. Update build commands to use craco CLI In particular in the : @@ -245,14 +251,15 @@ In particular in the : ```json "scripts": { - "start": "npm run copysqlwasm && craco start", - "build": "npm run copysqlwasm && craco build", - "copysqlwasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm public/assets", + "start": "craco start", + "build": "craco build", "ionic:build": "npm run build", "ionic:serve": "npm run start" }, ``` +There is no wasm copy step any more. The plugin ships `dist/web-worker.js` and `dist/sqlite3.wasm` itself and resolves them at runtime; only apps whose bundler cannot serve those files need `setSqliteWebOptions({ wasmUrl })` or `setSqliteWorkerFactory()`, both described in [Web Usage](Web-Usage.md). + #### 4. Add the following to tsconfig.json ```json diff --git a/docs/Web-Usage.md b/docs/Web-Usage.md index 6f48abec..42f8a78f 100644 --- a/docs/Web-Usage.md +++ b/docs/Web-Usage.md @@ -6,49 +6,223 @@ ## General to all applications -This is the description on how to use the Web part of the @capacitor-community/sqlite for development purpose. - -When your developement is fully tested, it will be a good idea to minimize the package size of your native app to remove the `jeep-sqlite` Stencil component and the `sql-wasm.wasm` file from the assets folder. - +This describes how to use the Web part of `@capacitor-community/sqlite`. It is a real SQLite, fit +for production, not only for development: the plugin runs +[`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm), the official SQLite wasm build, +inside a dedicated Worker that it creates and owns. All SQL and all persistence happen in that +worker; the main thread never touches the wasm heap. ```bash npm i --save @capacitor-community/sqlite@latest -npm i --save jeep-sqlite@latest ``` -`jeep-sqlite` is a Stencil Component which is using `sql.js` for sql in-memory queries and store the database in the Browser on a `localforage` IndexedDB store named `jeepSqliteStore` and inside a table named `databases`. +That is the whole install. There is no second package for the Web platform: the worker +(`dist/web-worker.js`) and the SQLite binary (`dist/sqlite3.wasm`) ship inside the plugin, and +there is no file to copy into your assets folder. + +`initWebStore()` is mandatory on Web and must complete before the first `createConnection`. It +boots the worker, selects the durability tier, and, the very first time it runs after an upgrade, +imports any database left behind by the previous `jeep-sqlite` implementation. + +## App Index + +* [`Requirements and browser support`](#requirements-and-browser-support) +* [`Durability tiers`](#durability-tiers) +* [`Backgrounding under Capacitor`](#backgrounding-under-capacitor) +* [`Serving the worker and the wasm`](#serving-the-worker-and-the-wasm) +* [`Migrating from jeep-sqlite`](#migrating-from-jeep-sqlite) +* [`Limitations`](#limitations) +* [`Ionic/Angular App`](#ionicangular-app) +* [`Ionic/Vue App`](#ionicvue-app) +* [`Ionic/React App`](#ionicreact-app) +* [`Troubleshooting`](#troubleshooting) + +## Requirements and browser support + +There are two floors, and they are not the same floor. The first decides whether the plugin runs +at all; the second decides only where your data rests. + +| Floor | Chromium / Android WebView | Safari / iOS WebKit | Firefox | Below it | +| ----- | -------------------------- | ------------------- | ------- | -------- | +| **Engine**: `BigInt`, optional chaining, nullish coalescing | 80 | 14 | 74 | **Nothing loads.** The worker fails to parse and `initWebStore()` rejects. | +| **Durability**: OPFS sync access handles | 108 | 16.4 | 111 | Everything works, on tier 2 below. Android WebView reached this in M132, January 2025. | + +The engine floor is not a limitation this plugin can lift. `BigInt` is a runtime dependency of +SQLite's int64 support and cannot be polyfilled, and the ES2020 syntax is upstream +`@sqlite.org/sqlite-wasm`'s own, so lowering your app's build target does not help and only breaks +your own code. Anything older than that floor is unsupported on the Web platform and fails loudly +rather than degrading. + +No COOP/COEP headers and no `SharedArrayBuffer` are required on either tier, which is what makes +this work unchanged inside Capacitor's `capacitor://` and `https://localhost` WebViews and on +ordinary static hosting. + +## Durability tiers + +`initWebStore()` probes the browser and settles on one of two tiers for the lifetime of the worker. + +**Tier 1, the normal case.** Databases are real files in the browser's Origin Private File System, +reached through SQLite's own `opfs-sahpool` VFS, in a pool directory named `.capacitor-sqlite`. +Every committed write is durable when the method resolves, exactly as on native. -🚨 The database is stored from in-memory to `localforage` IndexedDB store when one requires +**Tier 2, the automatic fallback** when the browser has no OPFS sync access handles. Databases are +opened `:memory:` and the whole database image is written to IndexedDB, in a database named +`capacitor-sqlite-store` under the object store `databases`. This is the model the previous +`jeep-sqlite` implementation used everywhere. + +🚨 On tier 2 the image is written to IndexedDB when one requires - a `saveToStore`, - a `close`, - - a `closeConnection`. + - a `closeConnection`, + +and additionally after an `importFromJson` and after a committed explicit transaction. 🚨 -## App Index +`saveToStore()` is a no-op on tier 1 and a real flush on tier 2, so calling it after a batch of +writes is the portable pattern and costs nothing where it is unnecessary. The `` +element's `autosave` attribute is gone and has no replacement: there is no element to put it on, +and on tier 1 there is nothing for it to do. -* [`Ionic/Angular App`](#ionic/angular-app) -* [`Ionic/Vue App`](#ionic/vue-app) -* [`Ionic/React App`](#ionic/react-app) +Only one browsing context may own the store, because OPFS access handles are single-owner by +design. If another tab of the same origin already holds it, `initWebStore()` rejects with an +explicit error rather than quietly opening an empty database over your data. -## Ionic/Angular App +**Tier 2 is not a one-way door.** A browser that lacked OPFS sync access handles and later gains +them, which is the normal update path for the devices that land on tier 2, would otherwise select +tier 1 and find an empty pool while the data sat in IndexedDB. Each `initWebStore()` on tier 1 +therefore moves any image left in the fallback store into the pool, verifies it, and only then +removes the original. Nothing is deleted until its replacement has been read back, so an +interrupted move resumes on the next start and a browser that drops back to tier 2 still finds +whatever has not moved yet. + +## Backgrounding under Capacitor + +WKWebView invalidates OPFS access handles when the OS suspends the app, so the store cannot simply +be left open across a background. When the plugin detects a native Capacitor platform it follows +the app itself: on the way out it closes every connection and pauses the VFS, and on the way back +it unpauses and reopens what it closed. A transaction open at that moment cannot survive the +close; it is rolled back and reported on the console rather than silently abandoned. + +If the gentle path cannot run, which is what a long suspension can do to a pool behind the +plugin's back, the worker is torn down and a fresh one takes the pool over, then reopens the same +connections. This is the recovery path, and it is why a suspension does not end in a dead store. -- **sql-wasm.wasm** - - Either copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `src/assets` folder of YOUR_APP - - or `npm i --save-dev copyfiles` and modify the scripts in the `package.json` file as follows: +Three methods are exported for apps that would rather drive this from `App.appStateChange`, which +is more precise than the visibility signal the plugin uses on its own. They are safe to call in +addition to the automatic wiring, since all three are idempotent: - ``` - "scripts": { - "ng": "ng", - "start": "npm run copysqlwasm && ng serve", - "build": "npm run copysqlwasm && ng build", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e", - "copysqlwasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm src/assets" - }, - ``` +```ts +await (CapacitorSQLite as any).pauseWebStore(); // close everything, pause the VFS +await (CapacitorSQLite as any).resumeWebStore(); // unpause and reopen, restarting if needed +await (CapacitorSQLite as any).restartWebStore(); // the heavy path on its own +``` + +On the plain web none of this is wired up: a browser tab going hidden is not a suspension, and +closing every connection on a tab switch would be a bug rather than a protection. + +## Serving the worker and the wasm -- For databases in the `src/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names +The plugin builds its worker from `dist/web-worker.js` inside the package, and that worker loads +`dist/sqlite3.wasm` sitting next to it. Whether your bundler follows that on its own depends on +the bundler, because the worker URL is computed at runtime rather than written as a literal +`new URL('...', import.meta.url)`. Serving the two files yourself is two lines and always works, +so prefer it if you would rather not find out: + +```ts +import { setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + +// Copy dist/web-worker.js and dist/sqlite3.wasm from the package into the folder your app serves +// as its web root (public/ for Vite, src/assets/ for Angular, and so on). Keeping them side by +// side is all the wasm needs: the worker resolves it as its own sibling. +setSqliteWorkerFactory(() => new Worker(new URL('web-worker.js', document.baseURI))); +``` + +Under Capacitor, files in the web root are copied into the native app by `npx cap sync`, so the +same two lines cover iOS and Android. + +**Vite needs this.** Measured with Vite 7: the plugin's runtime-computed URL is rewritten to point +at a module of the plugin's own that Vite emits as an asset, `web-worker.js` is never emitted, and +the request 404s. The symptom is an `initWebStore()` rejection quoting +`Unexpected token '<'`, which is the application's HTML answering the worker request. + +Two further escape hatches, both additive exports and both to be called before `initWebStore()`: + +```ts +import { setSqliteWebOptions, setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + +// 1. The wasm is served from somewhere else, for instance a CDN or a hashed asset path. +setSqliteWebOptions({ wasmUrl: '/assets/sqlite3.wasm' }); + +// 2. Your bundler does emit the worker, and you would rather it owned the URL. +setSqliteWorkerFactory(() => new Worker(new URL('./web-worker.js', import.meta.url))); +``` + +`setSqliteWebOptions` also accepts `assetsPath`, if your prepopulated databases are not served +from `assets/databases/`. + +One bundler note: a Vite build also emits `sqlite3-worker1.js` and `sqlite3-opfs-async-proxy.js`, +about 237 KiB together, because `@sqlite.org/sqlite-wasm`'s entry point references them with +`new URL`. This plugin uses neither and never fetches them at runtime; they are dead weight in the +output directory, and you can exclude them in your bundler configuration if the size matters. + +## Migrating from jeep-sqlite + +Nothing to do. The first `initWebStore()` after upgrading reads the old +`jeepSqliteStore` IndexedDB store, imports every database it finds into the active tier, and +verifies each one with `PRAGMA integrity_check` before retiring the legacy store. It runs once and +then records that it has run. + +Two details worth knowing if you are watching the console: + +- A leftover `backup-SQLite.db` key, which jeep-sqlite writes before a version upgrade and + deletes after it, is skipped rather than imported as a database in its own right, and does not + prevent the old store being retired. That only applies while `SQLite.db` is there too: a + `backup-` key on its own is the only copy of something and is migrated like any other database. +- A database that already exists under the same name is never written over. That cannot happen on + a normal upgrade, where nothing has been opened yet, but it can if you ran with + `skipJeepMigration` and later turned it off. The legacy database is reported as failed and both + copies are left intact for you to reconcile. +- If any database fails to import or fails its integrity check, nothing is deleted. The legacy + store is left exactly as it was and a warning naming the database is written to the console, so + the data is still recoverable by hand. The migration is not retried on the next boot: by then + the databases that did migrate are live, and re-importing the old images over them would undo + whatever the app has written since. + +The databases keep their names, so no application code changes. + +## Limitations + +- **No encryption.** There is no SQLCipher build for wasm. `createConnection` with + `encrypted: true` rejects, as do `setEncryptionSecret`, `changeEncryptionSecret`, + `clearEncryptionSecret`, `checkEncryptionSecret`, `isSecretStored`, `isDatabaseEncrypted`, + `isInConfigEncryption` and `isInConfigBiometricAuth`. +- **No WAL.** `opfs-sahpool` has no shared-memory support, so tier 1 stays on the `delete` journal + and a `PRAGMA journal_mode=WAL` is silently refused. Tier 2 runs on the `memory` journal. +- **Foreign keys are enforced.** `PRAGMA foreign_keys` is ON from the moment a database is opened, + which matches every other platform of this plugin. If your schema declares constraints it was + quietly violating on the old web engine, they will now be reported. +- **The soft-delete cascade needs rowids.** On a database that participates in sync, a DELETE is + recorded rather than performed, so sqlite never runs the `ON DELETE` actions itself and the + plugin applies them instead. It identifies affected rows by `rowid`, so a `WITHOUT ROWID` table + on the receiving end of a constraint with an `ON DELETE` action is reported as an error rather + than skipped. A referencing table that has no `sql_deleted` column is left alone: there is + nothing to mark, the parent row physically remains, and sqlite runs the real action later when + `deleteExportedRows` performs the actual delete. +- **Integers above 2^53 come back as `BigInt`.** This is a correctness improvement over the + previous engine, which silently lost precision, but `JSON.stringify` throws a `TypeError` on a + `BigInt`. Use `exportToJson`, which encodes out-of-range integers as decimal strings that SQLite + reads back identically on import, or supply your own replacer. +- **One owning tab per origin**, as described under [Durability tiers](#durability-tiers). +- **Not implemented on Web**: `getUrl`, the Cordova migration helpers (`getMigratableDbList`, + `addSQLiteSuffix`, `deleteOldDatabases`, `moveDatabasesAndAddSuffix`) and the non-conformed + database methods (`getNCDatabasePath`, `createNCConnection`, `closeNCConnection`, + `isNCDatabase`), which are all filesystem-path based and native-only. + +Read-only connections (`readonly: true`) are supported on Web, on both tiers. + +## Ionic/Angular App + +- For databases in the `src/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names (on Web that is every database, since encryption is unsupported there) ```json { @@ -60,13 +234,11 @@ npm i --save jeep-sqlite@latest } ``` -- open the `main.ts` file and add the following +- `main.ts` needs no web-specific step. There is no custom element to register, so bootstrap the + app as you normally would ```js ... -import { defineCustomElements as jeepSqlite} from 'jeep-sqlite/loader'; -... -jeepSqlite(window); platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.log(err)); ``` @@ -74,7 +246,7 @@ platformBrowserDynamic().bootstrapModule(AppModule) - open the `app.module.ts` file and add ```js -import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; ... import { SQLiteService } from './services/sqlite.service'; import { DetailService } from './services/detail.service'; @@ -89,16 +261,17 @@ import { DetailService } from './services/detail.service'; { provide: RouteReuseStrategy, useClass: IonicRouteStrategy } ], bootstrap: [AppComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], }) ``` -- open the `app-component.html` file and add + `CUSTOM_ELEMENTS_SCHEMA` is no longer needed: it was only there so Angular would tolerate the + `` tag in a template. + +- `app-component.html` needs no web-specific element either ```html - ``` @@ -115,7 +288,6 @@ import { SQLiteService } from './services/sqlite.service'; styleUrls: ['app.component.scss'] }) export class AppComponent { - public isWeb: boolean = false; private initPlugin: boolean; constructor( private platform: Platform, @@ -129,14 +301,13 @@ export class AppComponent { this.sqlite.initializePlugin().then(async (ret) => { this.initPlugin = ret; if( this.sqlite.platform === "web") { - this.isWeb = true; - await customElements.whenDefined('jeep-sqlite'); - const jeepSqliteEl = document.querySelector('jeep-sqlite'); - if(jeepSqliteEl != null) { + try { await this.sqlite.initWebStore(); - console.log(`>>>> isStoreOpen ${await jeepSqliteEl.isStoreOpen()}`); - } else { - console.log('>>>> jeepSqliteEl is null'); + } catch (err) { + // Two failure modes are worth telling the user apart: a browser below the engine + // floor, where the plugin cannot run at all, and another tab already owning the + // store. See the Troubleshooting section. + console.log(`>>>> initWebStore failed: ${err}`); } } @@ -252,7 +423,7 @@ export class SQLiteService { : Promise { if(this.sqlite != null) { try { - await this.sqlite.addUpgradeStatement(database, toVersion, statement); + await this.sqlite.addUpgradeStatement(database, toVersion, statements); return Promise.resolve(); } catch (err) { return Promise.reject(new Error(err)); @@ -383,9 +554,10 @@ export class SQLiteService { * @param encrypted * @param mode * @param version + * @param readonly read-only connections work on every platform, web included */ async createConnection(database:string, encrypted: boolean, - mode: string, version: number + mode: string, version: number, readonly = false ): Promise { if(this.sqlite != null) { try { @@ -399,7 +571,7 @@ export class SQLiteService { } */ const db: SQLiteDBConnection = await this.sqlite.createConnection( - database, encrypted, mode, version); + database, encrypted, mode, version, readonly); if (db != null) { return Promise.resolve(db); } else { @@ -652,8 +824,10 @@ export class SQLiteService { } /** - * Initialize the Web store - * @param database + * Initialize the Web store. + * Mandatory on web and must complete before the first createConnection. It boots the + * plugin's worker, selects the durability tier, and on its first run imports any database + * left behind by the previous jeep-sqlite implementation. */ async initWebStore(): Promise { if(this.platform !== 'web') { @@ -671,7 +845,9 @@ export class SQLiteService { } } /** - * Save a database to store + * Save a database to store. + * A no-op on tier 1, where OPFS writes are already durable, and the real flush of the + * database image to IndexedDB on tier 2. Safe and cheap to call unconditionally. * @param database */ async saveToStore(database:string): Promise { @@ -707,7 +883,7 @@ that is it. ## Ionic/Vue App -- copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +- Nothing to copy: see [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). - For databases in the `public/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names @@ -725,17 +901,12 @@ that is it. ```js ... -import { defineCustomElements as jeepSqlite, applyPolyfills } from "jeep-sqlite/loader"; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; import { useState } from '@/composables/state'; ... -applyPolyfills().then(() => { - jeepSqlite(window); -}); - window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) @@ -760,10 +931,6 @@ window.addEventListener('DOMContentLoaded', async () => { try { if(platform === "web") { - // Create the 'jeep-sqlite' Stencil component - const jeepSqlite = document.createElement('jeep-sqlite'); - document.body.appendChild(jeepSqlite); - await customElements.whenDefined('jeep-sqlite'); // Initialize the Web store await sqlite.initWebStore(); } @@ -1112,7 +1279,7 @@ that is it. ## Ionic/React App -- copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +- Nothing to copy: see [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). - For databases in the `public/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names @@ -1128,39 +1295,19 @@ that is it. - open the `index.tsx` file and add the following +There is no global JSX augmentation to write any more: with no custom element, TypeScript has +nothing extra to be taught. + ```ts ... -import { defineCustomElements as jeepSqlite, applyPolyfills, JSX as LocalJSX } from "jeep-sqlite/loader"; -import { HTMLAttributes } from 'react'; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; -type StencilToReact = { - [P in keyof T]?: T[P] & Omit, 'className'> & { - class?: string; - }; -} ; - -declare global { - export namespace JSX { - interface IntrinsicElements extends StencilToReact { - } - } -} - -applyPolyfills().then(() => { - jeepSqlite(window); -}); - window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) try { if(platform === "web") { - // add 'jeep-sqlite' Stencil component to the DOM - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - await customElements.whenDefined('jeep-sqlite'); // initialize the web store await sqlite.initWebStore(); } @@ -1508,4 +1655,37 @@ that is it. ## Troubleshooting -* The web-implementation uses IndexedDB to store the data. IndexedDB-support is checked via userAgent. Pay attention not to modify your userAgent (e.g. selecting an iPhone in the Chrome device simulator in your devtools), as this may break the user-agent check and result in an uninitialized DB (causing an error like this:`No available storage method found`). +* **`initWebStore()` rejects with a message about another tab or window.** The store is + single-owner per origin: OPFS access handles cannot be shared, so a second tab is refused + rather than being handed an empty database on top of your real data. Close the other tab. In + development, remember that a stale tab or a detached DevTools window still counts as a tab. + +* **`initWebStore()` rejects quoting `Unexpected token '<'`.** The worker URL 404s and the server + answered with your application's HTML, so this is a bundler problem and not a browser one, on + any browser. See [Serving the worker and the wasm](#serving-the-worker-and-the-wasm); with Vite + it is the expected default. The error the plugin raises says the same thing and carries the + code `WORKER_LOAD_FAILED`. + +* **`initWebStore()` rejects with `UNSUPPORTED_ENGINE` and some other `SyntaxError`.** + The browser is below the engine floor described under + [Requirements and browser support](#requirements-and-browser-support). This is not something the + plugin falls back from, and lowering your app's build target will not help: the syntax that + fails to parse belongs to `@sqlite.org/sqlite-wasm` itself. + +* **`sqlite3.wasm` cannot be found (a 404 in the network panel).** The worker looks for it as its + own sibling. Either put it next to the worker or point at it with + `setSqliteWebOptions({ wasmUrl })` from + [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). + +* **Data disappears between reloads.** You are almost certainly on tier 2 and never reached a + flush point. Call `saveToStore(database)` after your writes, or `closeConnection`. To confirm + which tier you are on, look for the databases: tier 1 puts them in the Origin Private File + System, tier 2 in IndexedDB under `capacitor-sqlite-store`. + +* **`JSON.stringify` throws `TypeError: Do not know how to serialize a BigInt`.** A column holds + an integer above 2^53. See [Limitations](#limitations). + +* **Databases from an older version of the app are missing.** Check the console for a migration + warning. If the one-time import from `jeep-sqlite` failed, nothing was deleted: the legacy + IndexedDB store `jeepSqliteStore` is still there and the warning names the database that could + not be read. diff --git a/docs/info_releases.md b/docs/info_releases.md index 6dc48e18..9d7a6fde 100644 --- a/docs/info_releases.md +++ b/docs/info_releases.md @@ -1,4 +1,100 @@ -## CAPACITOR 4 (Master) +## CAPACITOR 8 (Master) + +🚨 Release 8.2.0 web only ->> 🚨 + + iOS, Android and Electron are unchanged in this release. Everything below concerns the Web + platform only. + + The Web plugin is now implemented on `@sqlite.org/sqlite-wasm`, the official SQLite wasm build, + running in a dedicated Worker that ships with the plugin. It replaces `jeep-sqlite`, `sql.js` + and `localforage`, which are no longer dependencies of anything here. + + There are two durability tiers, chosen automatically by `initWebStore()`: + + - tier 1, databases are real files in the browser's `Origin Private File System` through the + `opfs-sahpool` VFS. No COOP/COEP headers and no `SharedArrayBuffer` are required, so it works + inside Capacitor WebViews and on ordinary hosting. Every committed write is durable. + - tier 2, the automatic fallback for browsers without OPFS sync access handles: databases are + opened `:memory:` and their whole-file image is written to IndexedDB, which is the model + jeep-sqlite used everywhere. + + `initWebStore` is still `MANDATORY` and is still called exactly as before. What must go is the + element bootstrap that used to surround it, first documented in release 3.2.3-1 below. Replace + + ```js + if(platform === "web") { + await customElements.whenDefined('jeep-sqlite'); + const jeepSqliteEl = document.querySelector('jeep-sqlite'); + if(jeepSqliteEl != null) { + await sqlite.initWebStore(); + } + } + ``` + + with + + ```js + if(platform === "web") { + await sqlite.initWebStore(); + } + ``` + + and delete the `jeep-sqlite` dependency, the `defineCustomElements`/`applyPolyfills` import, the + `` element from your templates and any script that copied `sql-wasm.wasm` into your + assets folder. The worker and `sqlite3.wasm` ship inside the package. + + `saveToStore` is also restated: it is a no-op on tier 1, where the data is already on disk, and + the real flush of the database image to IndexedDB on tier 2. Calling it after a batch of writes + remains the portable pattern. The `` `autosave` attribute is gone with the element. + + Existing data is migrated for you. The first `initWebStore()` after upgrading reads the old + `jeepSqliteStore` IndexedDB store, imports every database into the active tier, verifies each + one with `PRAGMA integrity_check`, and only then retires the legacy store. If anything fails, + nothing is deleted and a warning naming the database is written to the console. + + Two things happen for you, once each, at `initWebStore`: + + - Databases left in the IndexedDB fallback store by an older browser are moved into OPFS as + soon as the browser supports it. Without that an engine update, which is exactly what a + tier 2 user is waiting for, would leave them looking at an empty store. + - Databases from the previous `jeep-sqlite` store are imported, as described above. + + Under Capacitor native the store now follows the app in and out of the background: every + connection is closed and the VFS paused before the OS suspends the app, then reopened on + return, because WKWebView invalidates OPFS access handles across a suspension. If the gentle + path cannot run, the worker is restarted and the connections reopened from a fresh one. + `pauseWebStore()`, `resumeWebStore()` and `restartWebStore()` are available if you would rather + drive it from `App.appStateChange` yourself. + + Four behaviour changes worth knowing: + + - Foreign keys are enforced. `PRAGMA foreign_keys` is set ON at open, as it already was on + every other platform of this plugin. A schema that was quietly violating its own constraints + on the web will start saying so. + - A soft delete now follows foreign keys. When a database participates in sync, deleting a + parent applies each referencing constraint's `ON DELETE` action to the children, and to + their children, so an export no longer tells the server a parent is gone while reporting its + children live. + + - Read-only connections (`readonly: true`) now work on Web. + - Integers above 2^53 come back as `BigInt` rather than a silently truncated number. + `JSON.stringify` throws on those; `exportToJson` writes them as decimal strings. + - A failed upgrade now restores the pre-upgrade database and rejects, instead of handing back + a working connection at the old version with no signal that the migration failed. + + Two new limits, both loud rather than silent: + + - Minimum browser versions are Chrome/Android WebView 80, Safari 14, Firefox 74. Below that the + plugin does not load at all. + - Only one tab per origin may own the store; a second tab gets an explicit error. + + Encryption is still not supported on Web. + + See [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md). + +🚨 Release 8.2.0 <<- 🚨 + +## CAPACITOR 4 🚨 Release 4.0.1 all platforms ->> 🚨 diff --git a/package-lock.json b/package-lock.json index e42d3822..bea85e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "8.1.0", "license": "MIT", "dependencies": { - "jeep-sqlite": "^2.7.2" + "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fflate": "0.8.3" }, "devDependencies": { "@capacitor/android": "^8.0.0", @@ -22,15 +23,20 @@ "@ionic/swiftlint-config": "^2.0.0", "@rollup/plugin-commonjs": "28.0.2", "@rollup/plugin-node-resolve": "16.0.0", + "@rollup/plugin-terser": "0.4.4", "@types/node": "25.0.3", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", "commit-and-tag-version": "12.6.1", "eslint": "^8.57.1", + "playwright": "1.62.1", "prettier": "^3.6.2", "prettier-plugin-java": "^2.7.7", "rimraf": "^6.1.0", "rollup": "^4.53.2", "swiftlint": "^2.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "4.1.10" }, "engines": { "node": ">=16.0.0" @@ -60,6 +66,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@capacitor/android": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.0.0.tgz", @@ -200,6 +213,40 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "dev": true, @@ -473,11 +520,78 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -510,6 +624,284 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.2", "dev": true, @@ -558,6 +950,29 @@ } } }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "dev": true, @@ -934,17 +1349,54 @@ "dev": true, "license": "MIT" }, - "node_modules/@stencil/core": { - "version": "4.25.1", - "license": "MIT", - "bin": { - "stencil": "bin/stencil" - }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.53.0-build1", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.53.0-build1.tgz", + "integrity": "sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==", + "license": "Apache-2.0", + "workspaces": [ + "demos/*" + ], "engines": { - "node": ">=16.0.0", - "npm": ">=7.10.0" + "node": ">=22" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1194,6 +1646,176 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -1205,7 +1827,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1406,6 +2030,16 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "dev": true, @@ -1513,10 +2147,6 @@ "node": ">=8" } }, - "node_modules/browser-fs-access": { - "version": "0.35.0", - "license": "Apache-2.0" - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1614,6 +2244,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -2135,8 +2775,16 @@ "node": ">=14" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2336,6 +2984,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2588,6 +3246,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "dev": true, @@ -2948,6 +3613,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -3045,9 +3720,14 @@ } }, "node_modules/fdir": { - "version": "6.4.3", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -3057,6 +3737,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -3753,10 +4439,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.0", "dev": true, @@ -3801,6 +4483,7 @@ }, "node_modules/inherits": { "version": "2.0.4", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -4250,6 +4933,7 @@ }, "node_modules/isarray": { "version": "1.0.0", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -4269,17 +4953,6 @@ "lodash": "4.17.21" } }, - "node_modules/jeep-sqlite": { - "version": "2.8.0", - "license": "MIT", - "dependencies": { - "@stencil/core": "^4.20.0", - "browser-fs-access": "^0.35.0", - "jszip": "^3.10.1", - "localforage": "^1.10.0", - "sql.js": "^1.11.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -4381,16 +5054,6 @@ "node": "*" } }, - "node_modules/jszip": { - "version": "3.10.1", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4429,11 +5092,265 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "immediate": "~3.0.5" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -4467,20 +5384,6 @@ "node": ">=4" } }, - "node_modules/localforage": { - "version": "1.10.0", - "license": "Apache-2.0", - "dependencies": { - "lie": "3.1.1" - } - }, - "node_modules/localforage/node_modules/lie": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -4530,11 +5433,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/map-obj": { @@ -4868,11 +5773,40 @@ "node": ">=0.10.0" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-run": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", @@ -5017,6 +5951,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -5118,10 +6066,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/pako": { - "version": "1.0.11", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -5217,6 +6161,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5230,26 +6181,73 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/plist": { @@ -5267,6 +6265,16 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "dev": true, @@ -5275,6 +6283,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -5314,6 +6351,7 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", + "dev": true, "license": "MIT" }, "node_modules/prompts": { @@ -5373,6 +6411,16 @@ "node": ">=8" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -5530,6 +6578,7 @@ }, "node_modules/readable-stream": { "version": "2.3.8", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -5661,6 +6710,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -5753,6 +6836,7 @@ }, "node_modules/safe-buffer": { "version": "5.1.2", + "dev": true, "license": "MIT" }, "node_modules/safe-push-apply": { @@ -5809,6 +6893,16 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "dev": true, @@ -5852,10 +6946,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -5943,11 +7033,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "dev": true, @@ -5977,6 +7089,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5987,6 +7109,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -6046,12 +7189,23 @@ "node": ">= 10.x" } }, - "node_modules/sql.js": { - "version": "1.12.0", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, "license": "MIT" }, "node_modules/string_decoder": { "version": "1.1.1", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -6284,6 +7438,32 @@ "node": ">=18" } }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -6331,6 +7511,50 @@ "node": ">= 6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -6342,6 +7566,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -6572,6 +7806,7 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { @@ -6585,6 +7820,174 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -6684,6 +8087,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -6720,6 +8140,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "dev": true, diff --git a/package.json b/package.json index c4139b73..9931461d 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,12 @@ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web && npm run verify:electron", "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin OTHER_CFLAGS='-DHAVE_GETHOSTUUID=0' && cd ..", "verify:android": "cd android && ./gradlew clean build test && cd ..", - "verify:web": "npm run build", + "verify:web": "npm run build && npm run test", "verify:electron": "npm run build-electron", "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format", - "eslint": "eslint . --ext ts", - "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java", + "eslint": "eslint src test electron/src --ext ts", + "prettier": "prettier \"{src,test,electron/src,android/src,ios}/**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java", "swiftlint": "node-swiftlint", "docgen": "npm run docgenPlugin && npm run docgenConnection && npm run docgenDBConnection", "docgenPlugin": "docgen --api CapacitorSQLitePlugin --output-readme docs/API.md", @@ -56,7 +56,7 @@ "build-electron": "tsc --project electron/tsconfig.json && rollup -c electron/rollup.config.mjs && rimraf ./electron/build", "clean": "rimraf ./dist", "watch": "tsc --watch", - "test": "echo \"No test specified\"", + "test": "vitest run", "prepublishOnly": "npm run build && npm run build-electron && npm run docgen", "release": "commit-and-tag-version", "ios:spm:install": "swift package resolve" @@ -72,15 +72,20 @@ "@ionic/swiftlint-config": "^2.0.0", "@rollup/plugin-commonjs": "28.0.2", "@rollup/plugin-node-resolve": "16.0.0", + "@rollup/plugin-terser": "0.4.4", "@types/node": "25.0.3", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", "commit-and-tag-version": "12.6.1", "eslint": "^8.57.1", + "playwright": "1.62.1", "prettier": "^3.6.2", "prettier-plugin-java": "^2.7.7", "rimraf": "^6.1.0", "rollup": "^4.53.2", "swiftlint": "^2.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "4.1.10" }, "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -102,6 +107,7 @@ } }, "dependencies": { - "jeep-sqlite": "^2.7.2" + "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fflate": "0.8.3" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 912889a0..a5971545 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,24 +1,67 @@ -export default { - input: 'dist/esm/index.js', - output: [ - { - file: 'dist/plugin.js', - format: 'iife', - name: 'capacitorCapacitorSQLite', - globals: { - '@capacitor/core': 'capacitorExports', - localforage: 'localForage', - 'sql.js': 'initSqlJs', +import nodeResolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import { copyFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const wasmSource = require.resolve('@sqlite.org/sqlite-wasm/sqlite3.wasm'); + +/** + * The web worker ships as a prebuilt CLASSIC script rather than a module worker: OPFS sync + * access handles land in Firefox 111 but module workers only in Firefox 114, so an ESM worker + * would fail to load outright on browsers that can otherwise run the OPFS tier. Classic output + * costs nothing, the two builds being within a fraction of a KiB of each other. + * + * Rollup rewrites `import.meta.url` to a `document.currentScript` expression for iife output, + * which throws "document is not defined" inside a worker. `self.location.href` is the worker + * equivalent, and it is also what sqlite-wasm's own `new URL('sqlite3.wasm', import.meta.url)` + * needs in order to find the wasm sitting next to the worker file. + */ +const importMetaUrlInWorker = { + name: 'import-meta-url-in-worker', + resolveImportMeta(property) { + return property === 'url' ? 'self.location.href' : null; + }, +}; + +/** The wasm binary stays a separate asset. Base64 inlining it would cost about a third again. */ +const copyWasmAsset = { + name: 'copy-sqlite-wasm', + writeBundle() { + copyFileSync(wasmSource, 'dist/sqlite3.wasm'); + }, +}; + +export default [ + { + input: 'dist/esm/index.js', + output: [ + { + file: 'dist/plugin.js', + format: 'iife', + name: 'capacitorCapacitorSQLite', + globals: { + '@capacitor/core': 'capacitorExports', + }, + sourcemap: true, + inlineDynamicImports: true, }, + { + file: 'dist/plugin.cjs.js', + format: 'cjs', + sourcemap: true, + inlineDynamicImports: true, + }, + ], + external: ['@capacitor/core'], + }, + { + input: 'dist/esm/web/worker/worker.js', + output: { + file: 'dist/web-worker.js', + format: 'iife', sourcemap: true, - inlineDynamicImports: true, - }, - { - file: 'dist/plugin.cjs.js', - format: 'cjs', - sourcemap: true, - inlineDynamicImports: true, }, - ], - external: ['@capacitor/core', 'localforage', 'sql.js'], -}; + plugins: [nodeResolve({ browser: true }), importMetaUrlInWorker, terser(), copyWasmAsset], + }, +]; diff --git a/src/definitions.ts b/src/definitions.ts index fc0afff8..585ee69f 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -7,13 +7,23 @@ export interface CapacitorSQLitePlugin { /** * Initialize the web store * + * Mandatory on the web platform and must resolve before the first `createConnection`. It starts + * the plugin's SQLite worker, selects the durability tier (databases as files in OPFS, or as + * whole-file images in IndexedDB on browsers without OPFS sync access handles), and on its very + * first run imports any database left behind by the previous jeep-sqlite implementation. + * Rejects when another tab of the same origin already owns the store. + * * @return Promise * @since 3.2.3-1 */ initWebStore(): Promise; /** - * Save database to the web store + * Save database to the web store + * + * A no-op when the web store is on OPFS, where every committed write is already durable, and + * the real flush of the database image to IndexedDB on the fallback tier. Safe and cheap to + * call unconditionally after a batch of writes. * * @param options: capSQLiteOptions * @return Promise @@ -1058,12 +1068,20 @@ export interface capSQLiteVersionUpgrade { export interface ISQLiteConnection { /** * Init the web store + * + * Mandatory on the web platform and must resolve before the first `createConnection`. It starts + * the plugin's SQLite worker, selects the durability tier, and on its very first run imports any + * database left behind by the previous jeep-sqlite implementation. Rejects when another tab of + * the same origin already owns the store. * @returns Promise * @since 3.2.3-1 */ initWebStore(): Promise; /** - * Save the datbase to the web store + * Save the database to the web store + * + * A no-op when the web store is on OPFS, and the real flush of the database image to IndexedDB + * on the fallback tier. Safe and cheap to call unconditionally. * @param database * @returns Promise * @since 3.2.3-1 @@ -1138,10 +1156,10 @@ export interface ISQLiteConnection { /** * Create a connection to a database * @param database - * @param encrypted + * @param encrypted not available on the web platform * @param mode * @param version - * @param readonly + * @param readonly supported on every platform, the web included * @returns Promise * @since 2.9.0 refactor */ diff --git a/src/index.ts b/src/index.ts index 0e0575c2..65576651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,9 @@ const CapacitorSQLite = registerPlugin('CapacitorSQLite', export { CapacitorSQLite }; export * from './definitions'; +// Additive web-only exports. They do not appear in CapacitorSQLitePlugin and change no existing +// signature; they exist so bundlers that cannot resolve the shipped worker can supply their own. +export { setSqliteWorkerFactory, setSqliteWebOptions } from './web/worker-factory'; +export type { SqliteWorkerFactory } from './web/worker-factory'; +export { setSqliteLocalDiskAdapter } from './web/localdisk'; +export type { LocalDiskAdapter, PickedFile } from './web/localdisk'; diff --git a/src/web.ts b/src/web.ts index fc66fba6..4c47b908 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,4 +1,4 @@ -import { WebPlugin } from '@capacitor/core'; +import { Capacitor, WebPlugin } from '@capacitor/core'; import type { CapacitorSQLitePlugin, @@ -36,462 +36,619 @@ import type { capSQLiteExtensionPath, capSQLiteExtensionEnable, } from './definitions'; +import { WorkerClient } from './web/client'; +import { WEBSTORE_NOT_OPEN, messageOf, prefixed } from './web/errors'; +import { connectionNameFromFile, getLocalDiskAdapter } from './web/localdisk'; +import type { + JeepMigrationResult, + SerializedUpgrade, + Tier, + TierPromotionResult, + WorkerInitResult, +} from './web/protocol'; +import { EV_PICK_DATABASE_ENDED, EV_SAVE_TO_DISK, EV_HTTP_REQUEST_ENDED } from './web/protocol'; +import { ConnectionRegistry, parseKey, reconcile } from './web/registry'; +import { connKey, storageName } from './web/worker/paths'; +import { getSqliteWebOptions } from './web/worker-factory'; + +/** + * The one-time jeep-sqlite import is loud when it does something and louder when it cannot. + * A store this plugin fails to migrate leaves the legacy data exactly where it was, so the app + * still boots; the warning is what tells the developer their users' data is still in the old + * place. Silence means there was nothing to migrate. + */ +function reportMigration(migration: WorkerInitResult['migration']): void { + if (!migration) return; + if (migration.migrated.length > 0) { + console.info( + `[capacitor-sqlite] migrated ${migration.migrated.length} database(s) out of jeep-sqlite: ` + + `${migration.migrated.join(', ')}.`, + ); + } + if (migration.warning) console.warn(`[capacitor-sqlite] ${migration.warning}`); +} -export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlugin { - private jeepSqliteElement: any = null; - private isWebStoreOpen = false; - - async initWebStore(): Promise { - await customElements.whenDefined('jeep-sqlite'); +/** + * The tier-2 to tier-1 promotion. Silence means the fallback store was empty, which is the case + * for every installation that has only ever run on OPFS. + */ +function reportPromotion(promotion: WorkerInitResult['promotion']): void { + if (!promotion) return; + if (promotion.promoted.length > 0) { + console.info( + `[capacitor-sqlite] moved ${promotion.promoted.length} database(s) out of the IndexedDB ` + + `fallback store into OPFS: ${promotion.promoted.join(', ')}.`, + ); + } + if (promotion.warning) console.warn(`[capacitor-sqlite] ${promotion.warning}`); +} - this.jeepSqliteElement = document.querySelector('jeep-sqlite'); - this.ensureJeepSqliteIsAvailable(); +/** + * Web implementation backed by `@sqlite.org/sqlite-wasm` in a dedicated worker. + * + * Tier 1 stores databases in OPFS through the `opfs-sahpool` VFS, which needs neither COOP/COEP + * headers nor SharedArrayBuffer and therefore works inside Capacitor WebViews. Tier 2 is the + * automatic fallback for platforms without OPFS sync access handles: the same engine, with + * databases in `:memory:` and whole-file images in IndexedDB. + */ +export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlugin { + private client = new WorkerClient(); + private registry = new ConnectionRegistry(); + private upgrades = new Map(); + private store: WorkerInitResult | null = null; - this.jeepSqliteElement.addEventListener('jeepSqliteImportProgress', (event: CustomEvent) => { - this.notifyListeners('sqliteImportProgressEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteExportProgress', (event: CustomEvent) => { - this.notifyListeners('sqliteExportProgressEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteHTTPRequestEnded', (event: CustomEvent) => { - this.notifyListeners('sqliteHTTPRequestEndedEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqlitePickDatabaseEnded', (event: CustomEvent) => { - this.notifyListeners('sqlitePickDatabaseEndedEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteSaveDatabaseToDisk', (event: CustomEvent) => { - this.notifyListeners('sqliteSaveDatabaseToDiskEvent', event.detail); - }); + /** Connections closed by pauseWebStore, waiting to be reopened. Null when not paused. */ + private paused: { database: string; readonly: boolean }[] | null = null; + /** + * The pause currently in flight. `paused` above cannot be published until the worker answers, + * and on a real device the OS can freeze the page inside exactly that round trip, so a resume + * needs something to wait for that exists from the moment the pause starts. + */ + private pausing: Promise | null = null; + private visibilityListener: (() => void) | null = null; - if (!this.isWebStoreOpen) { - this.isWebStoreOpen = await this.jeepSqliteElement.isStoreOpen(); + async initWebStore(): Promise { + if (this.store) return; + try { + this.client.onEvent = (event, data) => this.notifyListeners(event, data); + await this.client.start(); + this.store = await this.client.call('init', getSqliteWebOptions()); + reportPromotion(this.store?.promotion); + reportMigration(this.store?.migration); + this.watchAppState(); + } catch (err) { + this.store = null; + throw prefixed('initWebStore', err); } + } - return; + /** + * Follow the app in and out of the background, but only under Capacitor native. + * + * WKWebView invalidates OPFS access handles when the app is suspended, which is why the store + * has to be closed and the VFS paused before that happens (PLAN 6.7). `visibilitychange` is the + * signal available without taking a dependency on `@capacitor/app`; an app that wants the + * precision of `App.appStateChange` can call `pauseWebStore` / `resumeWebStore` itself, and + * calling them while this listener is also active is harmless because both are idempotent. + * + * Deliberately not wired on the plain web: a browser tab going hidden is not a suspension, and + * closing every connection on a tab switch would be a bug rather than a protection. + */ + private watchAppState(): void { + if (this.visibilityListener) return; + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return; + if (!Capacitor.isNativePlatform?.()) return; + this.visibilityListener = () => { + const going = document.visibilityState === 'hidden'; + void (going ? this.pauseWebStore() : this.resumeWebStore()).catch((err) => { + console.warn(`[capacitor-sqlite] ${going ? 'pausing' : 'resuming'} the store failed: ${messageOf(err)}`); + }); + }; + document.addEventListener('visibilitychange', this.visibilityListener); } - async saveToStore(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + private unwatchAppState(): void { + if (this.visibilityListener && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.visibilityListener); + } + this.visibilityListener = null; + } - try { - await this.jeepSqliteElement.saveToStore(options); - return; - } catch (err) { - throw new Error(`${err}`); + /** + * Close every connection and pause the VFS, so the OS can suspend the app without leaving the + * pool's access handles half-alive. Idempotent, and a no-op on tier 2, which has no handles. + * + * `pauseVfs()` throws SQLITE_MISUSE while any database is open, so closing first is part of the + * operation rather than something the caller has to remember (M0 finding S4). + */ + async pauseWebStore(): Promise { + // A second caller joins the first rather than starting a competing pause, which is what keeps + // this idempotent alongside the automatic listener. + if (this.pausing) return this.pausing; + if (!this.store || this.paused) return; + const pausing = (async () => { + const result = await this.client.call('pause', {}); + this.paused = result.reopen ?? []; + if (result.interrupted?.length > 0) { + // In-flight transactions cannot survive the close. Rolled back, and said out loud. + console.warn( + `[capacitor-sqlite] rolled back an open transaction on ${result.interrupted.join(', ')} ` + + 'while pausing the store for the background.', + ); + } + })(); + this.pausing = pausing; + try { + await pausing; + } finally { + if (this.pausing === pausing) this.pausing = null; } } - async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + /** + * Unpause and reopen what pauseWebStore closed. When that fails, which is what a real device + * suspension can do to a pool behind our back, fall back to the heavy path: tear the worker + * down, start a new one, re-run init, and reopen from the same record (PLAN 6.7). + */ + async resumeWebStore(): Promise { + // Wait for a pause that has not finished publishing its state. Measured on an iPhone: iOS + // suspends the page inside the pause's worker round trip and then delivers the foreground + // signal first, so a resume that reads `paused` straight away sees null, returns a no-op, and + // the pause completes afterwards and closes every connection. The store would be left shut + // with nothing to reopen it until the app went round the cycle again (PLAN 18.4). + if (this.pausing) { + try { + await this.pausing; + } catch { + // A pause that failed published no state to restore; fall through to what it did set. + } + } + if (!this.store || !this.paused) return; + const reopen = this.paused; + this.paused = null; try { - await this.jeepSqliteElement.getFromLocalDiskToStore(options); - return; + await this.client.call('unpause', {}); + await this.reopenAll(reopen); } catch (err) { - throw new Error(`${err}`); + console.warn(`[capacitor-sqlite] unpause failed (${messageOf(err)}), restarting the worker.`); + await this.restartWebStore(reopen); } } - async saveToLocalDisk(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.saveToLocalDisk(options); - return; - } catch (err) { - throw new Error(`${err}`); + /** + * The heavy recovery path, also usable on its own: a fresh worker, a fresh VFS install, and the + * given connections reopened. A second worker can take over a paused pool, which is what makes + * this work at the engine level (M0 spike item 6). + */ + async restartWebStore(reopen?: { database: string; readonly: boolean }[]): Promise { + const wanted = reopen ?? this.registry.keys().map((key) => parseKey(key)); + this.paused = null; + this.client.terminate(); + this.client.onEvent = (event, data) => this.notifyListeners(event, data); + await this.client.start(); + this.store = await this.client.call('init', getSqliteWebOptions()); + await this.reopenAll(wanted); + } + + private async reopenAll(entries: { database: string; readonly: boolean }[]): Promise { + for (const { database, readonly } of entries) { + const version = this.registry.get(database, readonly)?.version ?? 1; + await this.client.call( + 'open', + { database, readonly, version, upgrades: this.upgrades.get(database) ?? [] }, + connKey(database, readonly), + ); } } - async echo(options: capEchoOptions): Promise { - this.ensureJeepSqliteIsAvailable(); + /** Which durability tier the store selected. Additive; not part of CapacitorSQLitePlugin. */ + getWebStoreTier(): Tier | null { + return this.store?.tier ?? null; + } - const echoResult = await this.jeepSqliteElement.echo(options); - return echoResult; + /** + * What the one-time jeep-sqlite import did on this boot, or null when it had nothing to do. + * Additive; not part of CapacitorSQLitePlugin. Useful for telling a user their data moved, and + * for noticing the warning path in an app that suppresses console output. + */ + getJeepMigration(): JeepMigrationResult | null { + return this.store?.migration ?? null; } - async createConnection(options: capConnectionOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + /** + * What the tier-2 to tier-1 promotion did on this boot, or null when there was nothing in the + * fallback store to move. Additive; not part of CapacitorSQLitePlugin. + */ + getTierPromotion(): TierPromotionResult | null { + return this.store?.promotion ?? null; + } - try { - await this.jeepSqliteElement.createConnection(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + /** + * Shut the worker down and release the single-owner lock, so another tab can take over. + * Additive; not part of CapacitorSQLitePlugin. Any open connection is dropped without a + * flush, so call `saveToStore` first if you are on tier 2 and care about unsaved changes. + */ + async closeWebStore(): Promise { + this.unwatchAppState(); + this.client.terminate(); + this.registry.clear(); + this.paused = null; + this.store = null; } - async open(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + private ensureStore(): WorkerInitResult { + if (!this.store) throw new Error(WEBSTORE_NOT_OPEN); + return this.store; + } - try { - await this.jeepSqliteElement.open(options); - return; - } catch (err) { - throw new Error(`${err}`); + private static optionValue(options: any, key: string, fallback?: T): T { + const value = options?.[key]; + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + throw new Error(`Must provide a ${key}`); } + return value as T; } - async closeConnection(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + private call(op: string, args: Record, database?: string, readonly?: boolean): Promise { + this.ensureStore(); + const key = database !== undefined ? connKey(database, !!readonly) : undefined; + return this.client.call(op, args, key); + } - try { - await this.jeepSqliteElement.closeConnection(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async echo(options: capEchoOptions): Promise { + return { value: options?.value }; } - async getVersion(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async saveToStore(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + await this.call('saveToStore', { database }, database, false); + } - try { - const versionResult: capVersionResult = await this.jeepSqliteElement.getVersion(options); - return versionResult; - } catch (err) { - throw new Error(`${err}`); + async createConnection(options: capConnectionOptions): Promise { + this.ensureStore(); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const version = options.version ?? 1; + const readonly = options.readonly ?? false; + if (options.encrypted) { + throw new Error('CreateConnection: encryption is not supported on the web platform'); } + this.registry.add(database, readonly, version); } - async checkConnectionsConsistency(options: capAllConnectionsOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - + async closeConnection(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); try { - const consistencyResult: capSQLiteResult = await this.jeepSqliteElement.checkConnectionsConsistency(options); - return consistencyResult; + await this.call('close', { database, readonly }, database, readonly); + } finally { + this.registry.delete(database, readonly); + this.client.releaseQueue(connKey(database, readonly)); + } + } + + async open(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + const entry = this.registry.require(database, readonly); + try { + await this.call( + 'open', + { database, readonly, version: entry.version, upgrades: this.upgrades.get(database) ?? [] }, + database, + readonly, + ); + entry.isOpen = true; } catch (err) { - throw new Error(`${err}`); + throw prefixed('Open', err); } } async close(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + const entry = this.registry.require(database, readonly); + await this.call('close', { database, readonly }, database, readonly); + entry.isOpen = false; + } - try { - await this.jeepSqliteElement.close(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async getVersion(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetVersion'); + return this.call('getVersion', { database, readonly }, database, readonly); } - async beginTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.beginTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); + async checkConnectionsConsistency(options: capAllConnectionsOptions): Promise { + const dbNames = options?.dbNames ?? []; + const openModes = options?.openModes ?? []; + const claimed = dbNames.map((name, index) => `${openModes[index] ?? 'RW'}_${name}`); + const { toClose, consistent } = reconcile(this.registry.keys(), claimed); + + for (const key of toClose) { + const { database, readonly } = parseKey(key); + try { + await this.call('close', { database, readonly }, database, readonly); + } catch { + // A connection that cannot be closed is still one we must forget about. + } + this.registry.delete(database, readonly); + this.client.releaseQueue(key); } + if (!consistent) this.registry.clear(); + return { result: consistent }; } - async commitTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.commitTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); - } + async beginTransaction(options: capSQLiteOptions): Promise { + return this.transactionOp('beginTransaction', options); + } + async commitTransaction(options: capSQLiteOptions): Promise { + return this.transactionOp('commitTransaction', options); } async rollbackTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + return this.transactionOp('rollbackTransaction', options); + } - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.rollbackTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); - } + private async transactionOp(op: string, options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, op); + const result = await this.call(op, { database, readonly }, database, readonly); + return { changes: { changes: result.changes, lastId: result.lastId } }; } - async isTransactionActive(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const result: capSQLiteResult = await this.jeepSqliteElement.isTransactionActive(options); - return result; - } catch (err) { - throw new Error(`${err}`); - } + async isTransactionActive(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'IsTransactionActive'); + return this.call('isTransactionActive', { database, readonly }, database, readonly); } async getTableList(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const tableListResult: capSQLiteValues = await this.jeepSqliteElement.getTableList(options); - return tableListResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetTableList'); + return this.call('getTableList', { database, readonly }, database, readonly); } async execute(options: capSQLiteExecuteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const executeResult: capSQLiteChanges = await this.jeepSqliteElement.execute(options); - return executeResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statements = CapacitorSQLiteWeb.optionValue(options, 'statements'); + const transaction = options.transaction ?? true; + this.rejectReadonly(options.readonly, 'Execute'); + this.requireOpen(database, false, 'Execute'); + const result = await this.call('execute', { database, readonly: false, statements, transaction }, database, false); + return { changes: { changes: result.changes, lastId: result.lastId } }; } async executeSet(options: capSQLiteSetOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const executeResult: capSQLiteChanges = await this.jeepSqliteElement.executeSet(options); - return executeResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const set = CapacitorSQLiteWeb.optionValue(options, 'set'); + const transaction = options.transaction ?? true; + const returnMode = options.returnMode ?? 'no'; + this.rejectReadonly(options.readonly, 'ExecuteSet'); + this.requireOpen(database, false, 'ExecuteSet'); + const result = await this.call( + 'executeSet', + { database, readonly: false, set, transaction, returnMode }, + database, + false, + ); + return { changes: CapacitorSQLiteWeb.changesOf(result) }; } async run(options: capSQLiteRunOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const runResult: capSQLiteChanges = await this.jeepSqliteElement.run(options); - return runResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statement = CapacitorSQLiteWeb.optionValue(options, 'statement'); + const transaction = options.transaction ?? true; + const returnMode = options.returnMode ?? 'no'; + this.rejectReadonly(options.readonly, 'Run'); + this.requireOpen(database, false, 'Run'); + const result = await this.call( + 'run', + { database, readonly: false, statement, values: options.values ?? [], transaction, returnMode }, + database, + false, + ); + return { changes: CapacitorSQLiteWeb.changesOf(result) }; } - async query(options: capSQLiteQueryOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const queryResult: capSQLiteValues = await this.jeepSqliteElement.query(options); - return queryResult; - } catch (err) { - throw new Error(`${err}`); - } + async query(options: capSQLiteQueryOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statement = CapacitorSQLiteWeb.optionValue(options, 'statement'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'Query'); + return this.call('query', { database, readonly, statement, values: options.values ?? [] }, database, readonly); } - async isDBExists(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const dbExistsResult: capSQLiteResult = await this.jeepSqliteElement.isDBExists(options); - return dbExistsResult; - } catch (err) { - throw new Error(`${err}`); - } + async isDBExists(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); + return this.call('isDatabase', { database }); } async isDBOpen(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const isDBOpenResult: capSQLiteResult = await this.jeepSqliteElement.isDBOpen(options); - return isDBOpenResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); + return this.call('isDBOpen', { database, readonly }); } async isDatabase(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const isDatabaseResult: capSQLiteResult = await this.jeepSqliteElement.isDatabase(options); - return isDatabaseResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + return this.call('isDatabase', { database }); } async isTableExists(options: capSQLiteTableOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const tableExistsResult = await this.jeepSqliteElement.isTableExists(options); - return tableExistsResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const table = CapacitorSQLiteWeb.optionValue(options, 'table'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'IsTableExists'); + return this.call('isTableExists', { database, readonly, table }, database, readonly); } - async deleteDatabase(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.deleteDatabase(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async deleteDatabase(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'DeleteDatabase'); + this.registry.require(database, false); + await this.call('deleteDatabase', { database }, database, false); + const entry = this.registry.get(database, false); + if (entry) entry.isOpen = false; } - async isJsonValid(options: capSQLiteImportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const isJsonValidResult = await this.jeepSqliteElement.isJsonValid(options); - return isJsonValidResult; - } catch (err) { - throw new Error(`${err}`); - } + async getDatabaseList(): Promise { + return this.call('getDatabaseList', {}); } - async importFromJson(options: capSQLiteImportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const upgrade = CapacitorSQLiteWeb.optionValue(options, 'upgrade'); + const serialized: SerializedUpgrade[] = upgrade.map((entry, index) => { + if (entry?.toVersion === undefined || !Array.isArray(entry?.statements)) { + throw new Error(`AddUpgradeStatement: upgrade[${index}] needs toVersion and statements`); + } + return { toVersion: entry.toVersion, statements: entry.statements }; + }); + const existing = this.upgrades.get(database) ?? []; + const merged = new Map(); + for (const item of [...existing, ...serialized]) merged.set(item.toVersion, item); + this.upgrades.set( + database, + [...merged.values()].sort((a, b) => a.toVersion - b.toVersion), + ); + } - try { - const importFromJsonResult: capSQLiteChanges = await this.jeepSqliteElement.importFromJson(options); - return importFromJsonResult; - } catch (err) { - throw new Error(`${err}`); - } + private requireOpen(database: string, readonly: boolean, context: string): void { + const entry = this.registry.require(database, readonly); + if (!entry.isOpen) throw new Error(`${context}: Database ${database} not opened`); } - async exportToJson(options: capSQLiteExportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + private rejectReadonly(readonly: boolean | undefined, context: string): void { + if (readonly) throw new Error(`${context}: not allowed in read-only mode`); + } - try { - const exportToJsonResult: capSQLiteJson = await this.jeepSqliteElement.exportToJson(options); - return exportToJsonResult; - } catch (err) { - throw new Error(`${err}`); - } + private static changesOf(result: any): { changes: number; lastId: number; values?: any[] } { + const changes: { changes: number; lastId: number; values?: any[] } = { + changes: result.changes, + lastId: result.lastId, + }; + if (result.values !== undefined) changes.values = result.values; + return changes; } - async createSyncTable(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const createSyncTableResult: capSQLiteChanges = await this.jeepSqliteElement.createSyncTable(options); - return createSyncTableResult; - } catch (err) { - throw new Error(`${err}`); - } + //////////////////////////////////// + ////// JSON, SYNC, ASSETS AND LOCAL DISK + //////////////////////////////////// + + async isJsonValid(options: capSQLiteImportOptions): Promise { + const jsonstring = CapacitorSQLiteWeb.optionValue(options, 'jsonstring'); + return this.call('isJsonValid', { jsonstring }); } - async setSyncDate(options: capSQLiteSyncDateOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.setSyncDate(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async importFromJson(options: capSQLiteImportOptions): Promise { + const jsonstring = CapacitorSQLiteWeb.optionValue(options, 'jsonstring'); + const result = await this.call('importFromJson', { jsonstring }); + return { changes: { changes: result.changes, lastId: result.lastId } }; } - async getSyncDate(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async exportToJson(options: capSQLiteExportOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const jsonexportmode = CapacitorSQLiteWeb.optionValue(options, 'jsonexportmode'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'ExportToJson'); + return this.call( + 'exportToJson', + { database, readonly, jsonexportmode, encrypted: options.encrypted ?? false }, + database, + readonly, + ); + } - try { - const getSyncDateResult: capSQLiteSyncDate = await this.jeepSqliteElement.getSyncDate(options); - return getSyncDateResult; - } catch (err) { - throw new Error(`${err}`); - } + async createSyncTable(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'CreateSyncTable'); + this.requireOpen(database, false, 'CreateSyncTable'); + const result = await this.call('createSyncTable', { database }, database, false); + return { changes: { changes: result.changes, lastId: result.lastId } }; } - async deleteExportedRows(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.deleteExportedRows(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + + async setSyncDate(options: capSQLiteSyncDateOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const syncdate = CapacitorSQLiteWeb.optionValue(options, 'syncdate'); + this.rejectReadonly(options.readonly, 'SetSyncDate'); + this.requireOpen(database, false, 'SetSyncDate'); + await this.call('setSyncDate', { database, syncdate }, database, false); } - async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async getSyncDate(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetSyncDate'); + return this.call('getSyncDate', { database, readonly }, database, readonly); + } - try { - await this.jeepSqliteElement.addUpgradeStatement(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async deleteExportedRows(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'DeleteExportedRows'); + this.requireOpen(database, false, 'DeleteExportedRows'); + await this.call('deleteExportedRows', { database }, database, false); } async copyFromAssets(options: capSQLiteFromAssetsOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - await this.jeepSqliteElement.copyFromAssets(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + const overwrite = options?.overwrite ?? true; + await this.call('copyFromAssets', { base: this.assetsBase(), overwrite }); } async getFromHTTPRequest(options: capSQLiteHTTPOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - + const url = CapacitorSQLiteWeb.optionValue(options, 'url'); + const overwrite = options.overwrite ?? true; try { - await this.jeepSqliteElement.getFromHTTPRequest(options); - return; + await this.call('getFromHTTPRequest', { url, overwrite }); + this.notifyListeners(EV_HTTP_REQUEST_ENDED, { message: 'ended' }); } catch (err) { - throw new Error(`${err}`); + this.notifyListeners(EV_HTTP_REQUEST_ENDED, { message: `Error: ${messageOf(err)}` }); + throw prefixed('GetFromHTTPRequest', err); } } - async getDatabaseList(): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - + async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { + this.ensureStore(); + const overwrite = options?.overwrite ?? true; try { - const databaseListResult: capSQLiteValues = await this.jeepSqliteElement.getDatabaseList(); - return databaseListResult; + const picked = await getLocalDiskAdapter().pickDatabase(); + if (!picked) { + this.notifyListeners(EV_PICK_DATABASE_ENDED, { message: 'User cancelled' }); + return; + } + const database = connectionNameFromFile(picked.name); + const result = await this.call('adoptImage', { database, bytes: picked.bytes, overwrite }); + this.notifyListeners(EV_PICK_DATABASE_ENDED, { db_name: result.storage, message: 'ended' }); } catch (err) { - throw new Error(`${err}`); + this.notifyListeners(EV_PICK_DATABASE_ENDED, { message: `Error: ${messageOf(err)}` }); + throw prefixed('GetFromLocalDiskToStore', err); } } - /** - * Checks if the `jeep-sqlite` element is present in the DOM. - * If it's not in the DOM, this method throws an Error. - * - * Attention: This will always fail, if the `intWebStore()` method wasn't called before. - */ - private ensureJeepSqliteIsAvailable() { - if (this.jeepSqliteElement === null) { - throw new Error( - `The jeep-sqlite element is not present in the DOM! Please check the @capacitor-community/sqlite documentation for instructions regarding the web platform.`, - ); + async saveToLocalDisk(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + try { + const { bytes } = await this.call('exportDb', { database }, database, false); + const fileName = storageName(database); + await getLocalDiskAdapter().saveDatabase(fileName, bytes); + this.notifyListeners(EV_SAVE_TO_DISK, { db_name: fileName, message: 'ended' }); + } catch (err) { + this.notifyListeners(EV_SAVE_TO_DISK, { message: `Error: ${messageOf(err)}` }); + throw prefixed('SaveToLocalDisk', err); } } - private ensureWebstoreIsOpen() { - if (!this.isWebStoreOpen) { - /** - * if (!this.isWebStoreOpen) - this.isWebStoreOpen = await this.jeepSqliteElement.isStoreOpen(); - */ - throw new Error('WebStore is not open yet. You have to call "initWebStore()" first.'); - } + /** Where copyFromAssets looks for `databases.json`, overridable through setSqliteWebOptions. */ + private assetsBase(): string { + const configured = getSqliteWebOptions().assetsPath; + const base = typeof document !== 'undefined' ? document.baseURI : self.location.href; + return new URL(configured ?? 'assets/databases/', base).href; } //////////////////////////////////// @@ -578,10 +735,12 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug async isInConfigBiometricAuth(): Promise { throw this.unimplemented('Not implemented on web.'); } + async loadExtension(options: capSQLiteExtensionPath): Promise { console.log('loadExtension', options); throw this.unimplemented('Not implemented on web.'); } + async enableLoadExtension(options: capSQLiteExtensionEnable): Promise { console.log('enableLoadExtension', options); throw this.unimplemented('Not implemented on web.'); diff --git a/src/web/client.ts b/src/web/client.ts new file mode 100644 index 00000000..d70e1bee --- /dev/null +++ b/src/web/client.ts @@ -0,0 +1,115 @@ +/** + * Main-thread half of the worker RPC. + * + * Two responsibilities beyond posting messages: + * + * - Ops that touch a specific connection are serialised per connection key (`RW_foo`), so two + * overlapping `run()` calls cannot interleave. The worker reads `changes()` inside the same op + * as the statement, which removes the read/write race; the queue removes the ordering surprise + * on top of it. + * - Errors come back as a payload and are rebuilt into a real Error here, instead of the + * `throw new Error(\`${err}\`)` stringification the jeep facade used. + */ +import { fromErrorPayload, workerLoadFailure } from './errors'; +import type { WorkerEvent, WorkerResponse } from './protocol'; +import { BOOT_ID, EVENT_ID } from './protocol'; +import { createSqliteWorker } from './worker-factory'; + +interface Pending { + resolve: (value: any) => void; + reject: (reason: unknown) => void; +} + +export class WorkerClient { + private worker: Worker | null = null; + private nextId = 1; + private pending = new Map(); + private queues = new Map>(); + private booted: Promise | null = null; + /** Set by the facade so worker-raised events reach notifyListeners. */ + onEvent: ((event: string, data: any) => void) | null = null; + + get isStarted(): boolean { + return this.worker !== null; + } + + start(): Promise { + if (this.booted) return this.booted; + const worker = createSqliteWorker(); + this.worker = worker; + + this.booted = new Promise((resolve, reject) => { + worker.onmessage = (event: MessageEvent) => { + const message = event.data as WorkerResponse; + if (!message || typeof message.id !== 'number') return; + if (message.id === BOOT_ID) { + resolve(); + return; + } + if (message.id === EVENT_ID) { + const raised = message as unknown as WorkerEvent; + this.onEvent?.(raised.event, raised.data); + return; + } + const entry = this.pending.get(message.id); + if (!entry) return; + this.pending.delete(message.id); + if (message.ok) entry.resolve(message.result); + else entry.reject(fromErrorPayload(message.error)); + }; + worker.onerror = (event) => { + const error = workerLoadFailure((event as ErrorEvent).message ?? ''); + reject(error); + this.failAll(error); + }; + }); + return this.booted; + } + + private failAll(error: Error): void { + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + } + + private dispatch(op: string, args: Record, transfer: Transferable[]): Promise { + const worker = this.worker; + if (!worker) return Promise.reject(new Error('The SQLite worker is not running.')); + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage({ id, op, args }, transfer); + }); + } + + /** + * @param serializeKey when given, this op waits for the previous op on the same key. Use the + * `RO_`/`RW_` connection key so per-connection ordering is guaranteed without serialising + * unrelated databases against each other. + */ + call(op: string, args: Record = {}, serializeKey?: string, transfer: Transferable[] = []): Promise { + if (!serializeKey) return this.dispatch(op, args, transfer); + const previous = this.queues.get(serializeKey) ?? Promise.resolve(); + const next = previous.then( + () => this.dispatch(op, args, transfer), + () => this.dispatch(op, args, transfer), + ); + // Keep a settled-either-way tail so one failure does not wedge the queue. + this.queues.set( + serializeKey, + next.catch(() => undefined), + ); + return next; + } + + releaseQueue(serializeKey: string): void { + this.queues.delete(serializeKey); + } + + terminate(): void { + this.worker?.terminate(); + this.worker = null; + this.booted = null; + this.queues.clear(); + this.failAll(new Error('The SQLite worker was terminated.')); + } +} diff --git a/src/web/errors.ts b/src/web/errors.ts new file mode 100644 index 00000000..448da781 --- /dev/null +++ b/src/web/errors.ts @@ -0,0 +1,103 @@ +/** + * Error plumbing for the web implementation. + * + * The jeep-based facade wrapped everything in `throw new Error(\`${err}\`)`, which turned a real + * Error into the string "Error: ...". Errors are rebuilt from the worker payload here instead, + * so `err.message` stays clean and the `name` survives the structured-clone boundary. + */ +import type { WorkerErrorPayload } from './protocol'; + +export const WEBSTORE_NOT_OPEN = 'WebStore is not open yet. You have to call "initWebStore()" first.'; + +export const MULTI_TAB_LOCKED = + 'Database is open in another tab or window. @capacitor-community/sqlite supports a single ' + + 'owning context per origin on the web; close the other tab and retry.'; + +/** + * A worker that fails to load has two very different causes, and telling them apart is the + * difference between a five-minute bundler fix and an unsupported browser. + * + * A parse failure means the engine is below the floor documented in docs/Web-Usage.md: the + * shipped worker and `@sqlite.org/sqlite-wasm` inside it are ES2020, and BigInt is a runtime + * dependency of int64 that no transpiler can supply. Anything else is almost always the bundler + * failing to serve `dist/web-worker.js` or `dist/sqlite3.wasm`. + * + * One parse failure is not an old engine at all, and it is the common one: a worker URL that + * 404s is answered with the application's own HTML, and the browser then reports + * `Unexpected token '<'` on a perfectly modern engine. Checked first, because a missing asset + * blamed on the user's browser sends them somewhere there is no fix. + * + * The raw browser message is always appended: a guess that hides the evidence is worse than no + * guess at all. + */ +export function workerLoadFailure(raw: string): SQLiteWebError { + const detail = raw && raw.trim().length > 0 ? raw.trim() : 'no further detail from the browser'; + if (/Unexpected token ['"`<]? Promise; + /** Hand bytes to the user as a download. */ + saveDatabase: (fileName: string, bytes: Uint8Array) => Promise; +} + +/** `` is the only picker that works without a secure-context gesture policy. */ +function defaultPickDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof document === 'undefined') { + reject(new Error('GetFromLocalDiskToStore: no document available to open a file picker')); + return; + } + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.db,.sqlite,.sqlite3,.zip,application/octet-stream'; + input.style.display = 'none'; + let settled = false; + + const finish = (value: PickedFile | null) => { + if (settled) return; + settled = true; + input.remove(); + resolve(value); + }; + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + finish({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) }); + }; + // A cancelled picker fires no change event in most browsers; window focus is the signal. + window.addEventListener('focus', () => setTimeout(() => finish(input.files?.[0] ? null : null), 500), { + once: true, + }); + document.body.appendChild(input); + input.click(); + }); +} + +function defaultSaveDatabase(fileName: string, bytes: Uint8Array): Promise { + if (typeof document === 'undefined') { + return Promise.reject(new Error('SaveToLocalDisk: no document available to start a download')); + } + // Copy into a fresh buffer: a Uint8Array that came over the worker boundary may be a view + // onto a larger buffer, and Blob would otherwise capture the whole thing. + const blob = new Blob([bytes.slice()], { type: 'application/vnd.sqlite3' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Revoking immediately can cancel the download in some browsers. + setTimeout(() => URL.revokeObjectURL(url), 10_000); + return Promise.resolve(); +} + +export const defaultLocalDiskAdapter: LocalDiskAdapter = { + pickDatabase: defaultPickDatabase, + saveDatabase: defaultSaveDatabase, +}; + +let adapter: LocalDiskAdapter = defaultLocalDiskAdapter; + +/** + * Replace the picker and download boundary. Additive, not part of CapacitorSQLitePlugin; it + * exists so the surrounding logic is testable and so an app with its own file UI can use it. + */ +export function setSqliteLocalDiskAdapter(next: LocalDiskAdapter | null): void { + adapter = next ?? defaultLocalDiskAdapter; +} + +export function getLocalDiskAdapter(): LocalDiskAdapter { + return adapter; +} + +/** `mydbSQLite.db` or `mydb.db` picked from disk -> the connection name `mydb`. */ +export function connectionNameFromFile(fileName: string): string { + let base = fileName.split(/[\\/]/).pop() ?? fileName; + for (const suffix of ['.sqlite3', '.sqlite', '.db']) { + if (base.toLowerCase().endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + if (base.endsWith('SQLite')) base = base.slice(0, -'SQLite'.length); + return base; +} diff --git a/src/web/protocol.ts b/src/web/protocol.ts new file mode 100644 index 00000000..6c6c2859 --- /dev/null +++ b/src/web/protocol.ts @@ -0,0 +1,170 @@ +/** + * Wire types shared by the facade and the worker. + * + * Envelope: `{ id, op, args }` request, `{ id, ok: true, result }` or + * `{ id, ok: false, error }` response. Values cross the boundary by structured clone, so + * `Uint8Array` BLOBs and `BigInt` int64s survive without any JSON step. + */ + +/** 1 = opfs-sahpool (durable files), 2 = :memory: plus whole-image persistence in IndexedDB. */ +export type Tier = 1 | 2; + +export interface WorkerInitArgs { + poolName: string; + directory: string; + /** Test hook: skip the VFS install entirely and run on tier 2. */ + forceTier2?: boolean; + /** Test hook: pretend the install rejected with this DOMException name. */ + simulateInstallError?: string; + /** Override where sqlite3.wasm is fetched from. Defaults to a sibling of the worker file. */ + wasmUrl?: string; + /** Where copyFromAssets looks for databases.json. Defaults to `assets/databases/`. */ + assetsPath?: string; + /** + * Skip the one-time import of jeep-sqlite's IndexedDB store. Only for an app that wants to + * keep the legacy store for its own reasons; the migration is otherwise safe to leave on, + * since it is a no-op once it has run and on any installation that never used jeep-sqlite. + */ + skipJeepMigration?: boolean; +} + +export interface WorkerInitResult { + tier: Tier; + sqliteVersion: string; + /** Reason the probe fell back, for logging. Absent on tier 1. */ + fallbackReason?: string; + /** Outcome of the one-time jeep-sqlite migration. Absent when it had already run. */ + migration?: JeepMigrationResult; + /** Outcome of the tier-2 to tier-1 promotion. Absent on tier 2 and when there was nothing to move. */ + promotion?: TierPromotionResult; +} + +/** + * What the tier-promotion pass did. Reported rather than thrown: an image that cannot be moved + * into the pool is still readable by a tier 2 context, so it is a warning, not a boot failure. + */ +export interface TierPromotionResult { + /** Storage names moved from the IndexedDB image store into the OPFS pool. */ + promoted: string[]; + /** Images whose name is already in the pool. The pool copy wins; the image is kept, not deleted. */ + conflicts: string[]; + /** Images that could not be moved. Still in the image store, retried on the next start. */ + failed: string[]; + warning?: string; +} + +/** + * What the one-time jeep-sqlite migration did. Reported rather than thrown: a store that cannot + * be migrated must leave the legacy data untouched and warn, not stop the app from booting. + */ +export interface JeepMigrationResult { + /** False when there was no legacy store to read. */ + ran: boolean; + /** Connection names imported into the active tier. */ + migrated: string[]; + /** Legacy keys deliberately passed over: `backup-*` copies and never-saved placeholders. */ + skipped: string[]; + /** Connection names whose import or verification failed. Empty on success. */ + failed: string[]; + legacyStoreDeleted: boolean; + warning?: string; +} + +export interface OpenArgs { + database: string; + readonly: boolean; + version: number; + upgrades: SerializedUpgrade[]; +} + +export interface SerializedUpgrade { + toVersion: number; + statements: string[]; +} + +export interface ExecResult { + changes: number; + lastId: number; + values?: any[]; +} + +export interface QueryResult { + values: any[]; +} + +export interface WorkerRequest { + id: number; + op: string; + args: Record; +} + +export interface WorkerErrorPayload { + message: string; + name?: string; + code?: string; +} + +export type WorkerResponse = + | { id: number; ok: true; result: any } + | { id: number; ok: false; error: WorkerErrorPayload }; + +/** Sent unsolicited by the worker as soon as its module body has run. */ +export const BOOT_ID = -1; + +/** + * Sent unsolicited by the worker to raise a plugin event. The facade forwards these to + * `notifyListeners`, which is how the five documented web events reach the app. + */ +export const EVENT_ID = -2; + +export interface WorkerEvent { + id: typeof EVENT_ID; + event: string; + data: any; +} + +/** The five events the web implementation has always emitted (PLAN 2.4). */ +export const EV_IMPORT_PROGRESS = 'sqliteImportProgressEvent'; +export const EV_EXPORT_PROGRESS = 'sqliteExportProgressEvent'; +export const EV_HTTP_REQUEST_ENDED = 'sqliteHTTPRequestEndedEvent'; +export const EV_PICK_DATABASE_ENDED = 'sqlitePickDatabaseEndedEvent'; +export const EV_SAVE_TO_DISK = 'sqliteSaveDatabaseToDiskEvent'; + +/** + * The IndexedDB database and object store backing tier 2 images. + * + * The database name is derived from the pool name so that the two tiers namespace identically. + * On tier 1 the pool name already isolates one store from another (M0 finding S11); without the + * same treatment here, two stores configured with different pool names would share their tier 2 + * databases while keeping their tier 1 ones separate, which is a difference nobody would expect + * to depend on which tier the browser happened to select. + */ +export const IMAGE_STORE_NAME = 'databases'; + +/** + * Companion store for bookkeeping that is not a database image, currently only the marker that + * says the one-time jeep-sqlite migration has already run. It is a second object store rather + * than a reserved key in `databases` so that `getDatabaseList()` on tier 2, which enumerates that + * store, cannot ever see it. + */ +export const META_STORE_NAME = 'meta'; + +/** Bumped from 1 to 2 when META_STORE_NAME was added. */ +export const IMAGE_STORE_VERSION = 2; + +export function imageStoreDbName(poolName: string): string { + return `${poolName}-store`; +} + +/** + * Web Locks name held for the lifetime of the worker so only one context owns the pool. + * Scoped by pool name: two stores configured with different pool names are genuinely + * independent on disk, so they must not gate each other. + */ +export function ownerLockName(poolName: string): string { + return `capacitor-sqlite-owner:${poolName}`; +} + +/** Defaults for the sahpool. The pool name is part of the on-disk contract: changing it orphans data. */ +export const DEFAULT_POOL_NAME = 'capacitor-sqlite'; +export const DEFAULT_POOL_DIRECTORY = '.capacitor-sqlite'; diff --git a/src/web/registry.ts b/src/web/registry.ts new file mode 100644 index 00000000..63ab2853 --- /dev/null +++ b/src/web/registry.ts @@ -0,0 +1,82 @@ +/** + * Connection registry, keyed the same way `SQLiteConnection._connectionDict` in + * `src/definitions.ts` keys its own map: `RW_` / `RO_`. Keeping the two in + * the same shape is what makes `checkConnectionsConsistency` able to compare them. + * + * Registration and opening are separate steps, exactly as on native: `createConnection` records + * the intent (name, version, mode) and `open` does the work. + */ +import { connKey } from './worker/paths'; + +export interface RegisteredConnection { + database: string; + readonly: boolean; + version: number; + isOpen: boolean; +} + +export class ConnectionRegistry { + private entries = new Map(); + + key(database: string, readonly: boolean): string { + return connKey(database, readonly); + } + + add(database: string, readonly: boolean, version: number): RegisteredConnection { + const entry: RegisteredConnection = { database, readonly, version, isOpen: false }; + this.entries.set(this.key(database, readonly), entry); + return entry; + } + + get(database: string, readonly: boolean): RegisteredConnection | undefined { + return this.entries.get(this.key(database, readonly)); + } + + require(database: string, readonly: boolean): RegisteredConnection { + const entry = this.get(database, readonly); + if (!entry) { + throw new Error(`No available connection for database ${database}. Call createConnection() first.`); + } + return entry; + } + + delete(database: string, readonly: boolean): void { + this.entries.delete(this.key(database, readonly)); + } + + keys(): string[] { + return [...this.entries.keys()]; + } + + all(): RegisteredConnection[] { + return [...this.entries.values()]; + } + + clear(): void { + this.entries.clear(); + } +} + +/** Decompose a registry key back into its parts. `RO_foo` -> `{ database: 'foo', readonly: true }`. */ +export function parseKey(key: string): { database: string; readonly: boolean } { + return { database: key.substring(3), readonly: key.substring(0, 3) === 'RO_' }; +} + +/** + * The comparison `checkConnectionsConsistency` performs, ported from + * `electron/src/index.ts` (MIT, this repo): the caller passes the connections it believes exist, + * and anything the plugin holds beyond that set is closed. A mismatch in the other direction + * (the caller knows about more than the plugin does) resets everything and reports false. + * + * @returns the keys to close, and whether the two sides agree afterwards. + */ +export function reconcile(held: string[], claimed: string[]): { toClose: string[]; consistent: boolean } { + const claimedSet = new Set(claimed); + if (claimedSet.size === 0) return { toClose: held, consistent: false }; + if (held.length < claimedSet.size) return { toClose: held, consistent: false }; + + const toClose = held.filter((key) => !claimedSet.has(key)); + const remaining = held.filter((key) => claimedSet.has(key)); + const consistent = remaining.length === claimedSet.size; + return { toClose: consistent ? toClose : held, consistent }; +} diff --git a/src/web/worker-factory.ts b/src/web/worker-factory.ts new file mode 100644 index 00000000..5f0bfb23 --- /dev/null +++ b/src/web/worker-factory.ts @@ -0,0 +1,64 @@ +/** + * How the worker gets constructed, and the two escape hatches around it. + * + * The shipped worker is a self-contained classic script at `dist/web-worker.js` with + * `sqlite3.wasm` beside it. Classic rather than a module worker on purpose: OPFS sync access + * handles land in Firefox 111 but module workers only in Firefox 114, so an ESM worker would + * fail to load outright on browsers that can otherwise run tier 1. + * + * Bundlers that inline this module lose the ability to resolve that URL, which is what + * `setSqliteWorkerFactory` is for. It is an additive export: it does not appear in + * `CapacitorSQLitePlugin` and does not change any existing signature. + */ +import { DEFAULT_POOL_DIRECTORY, DEFAULT_POOL_NAME } from './protocol'; +import type { WorkerInitArgs } from './protocol'; + +export type SqliteWorkerFactory = () => Worker; + +let workerFactory: SqliteWorkerFactory | null = null; + +const options: WorkerInitArgs = { + poolName: DEFAULT_POOL_NAME, + directory: DEFAULT_POOL_DIRECTORY, +}; + +/** + * Supply your own worker, for bundlers that cannot resolve the shipped one. + * + * ```ts + * import { setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + * setSqliteWorkerFactory(() => new Worker(new URL('...', import.meta.url), { type: 'module' })); + * ``` + */ +export function setSqliteWorkerFactory(factory: SqliteWorkerFactory | null): void { + workerFactory = factory; +} + +/** + * Override the pool identity or force the IndexedDB tier. + * + * The pool name and directory are part of the on-disk contract: changing them on an existing + * installation orphans the databases already stored under the old name. + */ +export function setSqliteWebOptions(overrides: Partial): void { + Object.assign(options, overrides); +} + +export function getSqliteWebOptions(): WorkerInitArgs { + return { ...options }; +} + +/** + * `dist/web-worker.js` relative to whichever build is running: `dist/esm/web/worker-factory.js` + * for the module build, `dist/plugin.js` for the iife/cjs bundles. + */ +function defaultWorkerUrl(): URL { + const here = new URL('.', import.meta.url); + const root = here.pathname.endsWith('/esm/web/') ? new URL('../../', here) : here; + return new URL('web-worker.js', root); +} + +export function createSqliteWorker(): Worker { + if (workerFactory) return workerFactory(); + return new Worker(defaultWorkerUrl()); +} diff --git a/src/web/worker/adoption.ts b/src/web/worker/adoption.ts new file mode 100644 index 00000000..729569d2 --- /dev/null +++ b/src/web/worker/adoption.ts @@ -0,0 +1,33 @@ +/** + * What it means to take a whole database image and make it one of this store's databases. + * + * Two callers share it: the one-time import from jeep-sqlite (`migrate-jeep.ts`) and the + * tier-2 to tier-1 promotion (`promote.ts`). Both hand over bytes from somewhere else, both must + * prove the result is a readable database before they let go of the original, and neither may + * ever write over a database that is already in the store. + */ + +/** The first 16 bytes of every SQLite file are these 15 characters followed by a NUL. */ +const SQLITE_HEADER = 'SQLite format 3'; + +export interface AdoptionTarget { + /** True when the store already holds a database of that name. Checked before every adopt. */ + exists(storage: string): Promise; + adopt(storage: string, bytes: Uint8Array): Promise; + /** Open what was adopted and run `PRAGMA integrity_check`. Throws when it is not a database. */ + verify(storage: string): Promise; + /** Undo an adopt whose verification failed, so no unreadable file is left behind. */ + discard(storage: string): Promise; +} + +/** + * A cheap gate before handing bytes to the engine. On tier 2 `sqlite3_deserialize` accepts almost + * anything and only fails later, so without this a stray value would become a database. + */ +export function looksLikeSQLite(bytes: Uint8Array): boolean { + if (bytes.byteLength < SQLITE_HEADER.length) return false; + for (let i = 0; i < SQLITE_HEADER.length; i++) { + if (bytes[i] !== SQLITE_HEADER.charCodeAt(i)) return false; + } + return true; +} diff --git a/src/web/worker/assets.ts b/src/web/worker/assets.ts new file mode 100644 index 00000000..3678237e --- /dev/null +++ b/src/web/worker/assets.ts @@ -0,0 +1,184 @@ +/** + * copyFromAssets and getFromHTTPRequest. + * + * Behaviour ported from jeep-sqlite (MIT) and `electron/src/electron-utils/utilsFile.ts` + * (MIT, this repo): the asset copy reads `assets/databases/databases.json` as a manifest and + * pulls each listed file, where a `.zip` entry is an archive of databases rather than a + * database. + * + * Streaming. A plain database download is fed to `importDb`'s async-callback form one network + * chunk at a time, so a multi-hundred-megabyte bundle costs one chunk of memory rather than its + * own size (M0 item 2 measured 1.10 MiB against 65.02 MiB for the whole-buffer path). No + * MessagePort is involved because producer and consumer are both inside this worker; the pull + * protocol in PLAN 3.1/A5 exists for the additive API in section 8.1, where the main thread owns + * the source. + * + * Zip is the exception and is materialised: the archive has to be in memory to be inflated. That + * is a property of the container, not of our path, and it is the reason `.zip` assets should be + * kept modest. Recorded in section 13. + */ +import { unzipSync } from 'fflate'; + +import { prefixed } from '../errors'; + +import { poolPath, reserveCapacity, storageName } from './paths'; + +export interface AssetTarget { + /** tier 1 pool, or null on tier 2 where images land in IndexedDB instead. */ + poolUtil: any | null; + /** Adopt a whole image (tier 2, and the zip path on either tier). */ + adopt: (storage: string, bytes: Uint8Array) => Promise; + exists: (storage: string) => Promise; +} + +const DB_SUFFIXES = ['.db', '.sqlite', '.sqlite3']; + +function isDatabaseName(name: string): boolean { + const lower = name.toLowerCase(); + return DB_SUFFIXES.some((suffix) => lower.endsWith(suffix)); +} + +/** `foo.db` / `fooSQLite.db` -> the plugin's storage name `fooSQLite.db`. */ +export function assetStorageName(fileName: string): string { + let base = fileName; + for (const suffix of DB_SUFFIXES) { + if (base.toLowerCase().endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + return storageName(base); +} + +/** + * Stream a response body straight into the pool. Falls back to buffering only when the response + * has no readable stream, which is the case for some polyfilled fetch implementations. + */ +async function streamIntoPool(target: AssetTarget, storage: string, response: Response): Promise { + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + await target.adopt(storage, bytes); + return bytes.byteLength; + } + await reserveCapacity(target.poolUtil, 2); + const reader = response.body.getReader(); + let written = 0; + await target.poolUtil.importDb(poolPath(storage), async () => { + const { done, value } = await reader.read(); + if (done || !value) return undefined; + written += value.byteLength; + return value; + }); + return written; +} + +async function fetchOk(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) throw new Error(`request for ${url} failed with status ${response.status}`); + return response; +} + +/** One database file from a URL into storage, streamed when the tier allows it. */ +export async function importFromUrl( + target: AssetTarget, + storage: string, + url: string, + overwrite: boolean, +): Promise { + if (!overwrite && (await target.exists(storage))) return false; + const response = await fetchOk(url); + if (target.poolUtil) { + await streamIntoPool(target, storage, response); + } else { + // Tier 2 stores a whole image anyway, so streaming would buy nothing here. + await target.adopt(storage, new Uint8Array(await response.arrayBuffer())); + } + return true; +} + +function looksLikeSqlite(bytes: Uint8Array): boolean { + const header = 'SQLite format 3'; + if (bytes.byteLength < header.length) return false; + for (let i = 0; i < header.length; i++) if (bytes[i] !== header.charCodeAt(i)) return false; + return true; +} + +/** Every database inside a zip archive, keyed by its storage name. */ +export function databasesFromZip(archive: Uint8Array): { storage: string; bytes: Uint8Array }[] { + const entries = unzipSync(archive); + const out: { storage: string; bytes: Uint8Array }[] = []; + for (const [name, bytes] of Object.entries(entries)) { + const leaf = name.split('/').pop() ?? name; + if (leaf.length === 0 || leaf.startsWith('.')) continue; + if (!isDatabaseName(leaf) && !looksLikeSqlite(bytes)) continue; + out.push({ storage: assetStorageName(leaf), bytes }); + } + return out; +} + +export interface CopyFromAssetsResult { + copied: string[]; + skipped: string[]; +} + +/** + * @param base the directory holding `databases.json`, normally `assets/databases/` relative to + * the app's base URL. + */ +export async function copyFromAssets( + target: AssetTarget, + base: string, + overwrite: boolean, +): Promise { + const manifestUrl = new URL('databases.json', base).href; + let manifest: any; + try { + manifest = await (await fetchOk(manifestUrl)).json(); + } catch (err) { + throw prefixed('CopyFromAssets', err); + } + const files: string[] = Array.isArray(manifest) ? manifest : (manifest?.databaseList ?? []); + if (!Array.isArray(files) || files.length === 0) { + throw new Error('CopyFromAssets: databases.json does not list any database'); + } + + const copied: string[] = []; + const skipped: string[] = []; + for (const file of files) { + const url = new URL(file, base).href; + if (file.toLowerCase().endsWith('.zip')) { + const archive = new Uint8Array(await (await fetchOk(url)).arrayBuffer()); + for (const entry of databasesFromZip(archive)) { + if (!overwrite && (await target.exists(entry.storage))) { + skipped.push(entry.storage); + continue; + } + await target.adopt(entry.storage, entry.bytes); + copied.push(entry.storage); + } + continue; + } + const storage = assetStorageName(file); + if (await importFromUrl(target, storage, url, overwrite)) copied.push(storage); + else skipped.push(storage); + } + return { copied, skipped }; +} + +/** getFromHTTPRequest: one database at a URL, streamed into storage. */ +export async function getFromHTTPRequest(target: AssetTarget, url: string, overwrite: boolean): Promise { + const leaf = new URL(url, 'http://localhost/').pathname.split('/').pop() ?? 'downloaded.db'; + if (leaf.toLowerCase().endsWith('.zip')) { + const archive = new Uint8Array(await (await fetchOk(url)).arrayBuffer()); + const entries = databasesFromZip(archive); + if (entries.length === 0) throw new Error('GetFromHTTPRequest: the archive contains no database'); + for (const entry of entries) { + if (!overwrite && (await target.exists(entry.storage))) continue; + await target.adopt(entry.storage, entry.bytes); + } + return entries[0].storage; + } + const storage = assetStorageName(leaf); + await importFromUrl(target, storage, url, overwrite); + return storage; +} diff --git a/src/web/worker/cascade.ts b/src/web/worker/cascade.ts new file mode 100644 index 00000000..c35de1d0 --- /dev/null +++ b/src/web/worker/cascade.ts @@ -0,0 +1,261 @@ +/** + * Foreign-key propagation for the soft delete. + * + * A DELETE against a sync-enabled database is rewritten into `UPDATE ... SET sql_deleted = 1` + * (`statements.ts`), which means sqlite never sees a delete and never fires the `ON DELETE` + * actions the schema declares. Without this module the parent row is marked and its children are + * not, so the next export tells the server the parent is gone while still reporting the children + * live, and the two ends diverge with no error on either side. + * + * Ported in behaviour from `electron/src/electron-utils/utilsDelete.ts` and the + * `findReferencesAndUpdate` / `getReferences` / `searchForRelatedItems` group in + * `utilsSQLite.ts` (MIT, this repo), with three deliberate differences: + * + * 1. The relationships come from `PRAGMA foreign_key_list`, not from regex-matching + * `sqlite_master`. The port source's pattern only recognises table-level `FOREIGN KEY (...) + * REFERENCES` clauses, misses column-level `REFERENCES`, and reads only the first matching + * table, so a parent with two referencing tables cascades into one of them. The pragma is + * sqlite's own answer to the same question and has none of those limits. + * 2. It recurses. The port source propagates one level, so grandchildren are left live under a + * deleted grandparent, which is the same divergence one level down. + * 3. Affected rows are identified by `rowid` and marked before the recursion descends, which is + * what makes a cyclic or self-referencing schema terminate: a marked row no longer satisfies + * `sql_deleted = 0` and cannot be reached twice. + */ +import { messageOf } from '../errors'; + +import type { Connection } from './engine'; +import { quoteIdent } from './statements'; + +/** sqlite's own spelling of the actions, as `PRAGMA foreign_key_list` reports them. */ +export type DeleteAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; + +export interface ForeignKey { + /** The table holding the reference. */ + child: string; + /** The parent it points at. */ + parent: string; + /** Referencing columns, in constraint order. */ + from: string[]; + /** Referenced columns, resolved to the parent's primary key when the schema left them implicit. */ + to: string[]; + onDelete: DeleteAction; +} + +/** How many rowids go into one `IN (...)` list. SQLITE_MAX_VARIABLE_NUMBER is far higher. */ +const CHUNK = 500; + +/** + * Depth guard. Marking before descending already terminates cycles, so this is a backstop against + * a case nobody foresaw rather than the mechanism that stops them. Set well above any tree an app + * would build so a legitimate deep hierarchy is never refused. + */ +const MAX_DEPTH = 256; + +function normalizeAction(value: unknown): DeleteAction { + const action = String(value ?? 'NO ACTION') + .toUpperCase() + .replace(/\s+/g, ' ') + .trim(); + switch (action) { + case 'CASCADE': + case 'SET NULL': + case 'SET DEFAULT': + case 'RESTRICT': + return action; + default: + return 'NO ACTION'; + } +} + +/** Quoted identifier, so a table or column called `order` or `group` still works. */ +const ident = quoteIdent; + +/** + * The bare table name behind whatever the caller wrote. `extractTableName` hands back the token + * as it appeared in the statement, which may be quoted (`"my table"`, `[items]`) or qualified + * (`main.items`), and neither form can be quoted again or compared against + * `PRAGMA foreign_key_list` output. + * + * A qualifier other than `main` would mean an attached database, which this plugin never creates. + * It is refused rather than silently skipped, because a cascade that quietly does nothing is the + * failure this module exists to remove. + */ +export function resolveTableName(raw: string): string { + let name = raw.trim(); + const qualified = name.match(/^([A-Za-z_][A-Za-z0-9_$]*)\.(.+)$/); + if (qualified) { + if (qualified[1].toLowerCase() !== 'main') { + throw new Error( + `Cascade: cannot follow foreign keys for ${raw}, which names an attached database. ` + + 'Reference the table without the schema qualifier.', + ); + } + name = qualified[2].trim(); + } + if (name.startsWith('"') && name.endsWith('"')) return name.slice(1, -1).replace(/""/g, '"'); + if (name.startsWith('[') && name.endsWith(']')) return name.slice(1, -1); + if (name.startsWith('`') && name.endsWith('`')) return name.slice(1, -1).replace(/``/g, '`'); + return name; +} + +function primaryKeyColumns(conn: Connection, table: string): string[] { + return conn + .query(`PRAGMA table_info(${ident(table)})`) + .filter((row: any) => Number(row.pk) > 0) + .sort((a: any, b: any) => Number(a.pk) - Number(b.pk)) + .map((row: any) => String(row.name)); +} + +/** + * Every foreign key in the database, grouped by constraint. `PRAGMA foreign_key_list` returns one + * row per column of a composite key, sharing an `id`. + */ +export function foreignKeys(conn: Connection): ForeignKey[] { + const out: ForeignKey[] = []; + for (const child of conn.tableList()) { + const rows = conn.query(`PRAGMA foreign_key_list(${ident(child)})`); + const byConstraint = new Map(); + for (const row of rows) { + const id = Number((row as any).id); + const list = byConstraint.get(id); + if (list) list.push(row); + else byConstraint.set(id, [row]); + } + for (const list of byConstraint.values()) { + list.sort((a: any, b: any) => Number(a.seq) - Number(b.seq)); + const parent = String(list[0].table); + const from = list.map((row: any) => String(row.from)); + // `to` is null when the constraint referenced the parent's primary key implicitly. + const to = list.every((row: any) => row.to != null) + ? list.map((row: any) => String(row.to)) + : primaryKeyColumns(conn, parent); + if (to.length !== from.length) continue; // malformed or unresolvable, leave it to sqlite + out.push({ child, parent, from, to, onDelete: normalizeAction(list[0].on_delete) }); + } + } + return out; +} + +function columnDefault(conn: Connection, table: string, column: string): string { + const row = conn.query(`PRAGMA table_info(${ident(table)})`).find((entry: any) => entry.name === column); + const value = row ? (row as any).dflt_value : null; + return value === null || value === undefined ? 'NULL' : String(value); +} + +/** + * `rowid` is how an affected row is carried between steps. A WITHOUT ROWID table has none, and + * silently skipping it would be exactly the divergence this module exists to prevent, so it is + * reported instead. + */ +function rowidsOf(conn: Connection, table: string, where: string, values: any[]): number[] { + try { + return conn + .query(`SELECT rowid AS rid FROM ${ident(table)} WHERE ${where}`, values) + .map((row: any) => Number(row.rid)); + } catch (err) { + if (/no such column: rowid/i.test(messageOf(err))) { + throw new Error( + `Cascade: ${table} is a WITHOUT ROWID table, which the soft-delete cascade cannot follow. ` + + 'Give it a rowid or drop the ON DELETE action from the constraint that points at it.', + ); + } + throw err; + } +} + +function chunked(rowids: number[]): number[][] { + const out: number[][] = []; + for (let i = 0; i < rowids.length; i += CHUNK) out.push(rowids.slice(i, i + CHUNK)); + return out; +} + +function inRowids(rowids: number[]): string { + return `rowid IN (${rowids.map(() => '?').join(', ')})`; +} + +/** + * Rows of `fk.child` that reference any of `parentRowids` and are still live. + * The join is on the constraint's own column pairs, so composite keys work unchanged, and a NULL + * referencing column never matches, which is what `ON DELETE` does too. + */ +function childRowids(conn: Connection, fk: ForeignKey, parentRowids: number[]): number[] { + const on = fk.from.map((from, index) => `c.${ident(from)} = p.${ident(fk.to[index])}`).join(' AND '); + const found: number[] = []; + for (const chunk of chunked(parentRowids)) { + const rows = conn.query( + `SELECT DISTINCT c.rowid AS rid FROM ${ident(fk.child)} c JOIN ${ident(fk.parent)} p ON ${on} ` + + `WHERE p.${inRowids(chunk)} AND c.sql_deleted = 0`, + chunk, + ); + for (const row of rows) found.push(Number((row as any).rid)); + } + return found; +} + +function updateRows(conn: Connection, table: string, setClause: string, rowids: number[]): void { + for (const chunk of chunked(rowids)) { + // rewriteDeletes stays off: this IS the rewrite's own work, and it is an UPDATE regardless. + conn.run(`UPDATE ${ident(table)} SET ${setClause} WHERE ${inRowids(chunk)}`, chunk, false, false); + } +} + +/** + * Apply every `ON DELETE` action that points at `table`, for the rows identified by `rowids`, + * then descend into whatever those actions marked. + * + * A constraint is followed only when the child table carries `sql_deleted`. A child without it is + * not part of the sync protocol and there is nothing to mark; its integrity is not at risk either, + * because the parent row physically remains until `deleteExportedRows` removes it, and that is a + * real DELETE which fires sqlite's own `ON DELETE` handling. + */ +function propagate(conn: Connection, table: string, rowids: number[], depth: number): void { + if (rowids.length === 0) return; + if (depth > MAX_DEPTH) { + throw new Error(`Cascade: foreign key propagation from ${table} exceeded ${MAX_DEPTH} levels`); + } + + for (const fk of conn.foreignKeyGraph.filter((entry) => equalNames(entry.parent, table))) { + if (fk.onDelete === 'NO ACTION') continue; + // Reuses the connection's own per-table cache rather than a PRAGMA per constraint per delete. + if (!conn.softDeletes(fk.child)) continue; + + const affected = childRowids(conn, fk, rowids); + if (affected.length === 0) continue; + + if (fk.onDelete === 'RESTRICT') { + throw new Error('Restrict mode related items exist, please delete them first'); + } + if (fk.onDelete === 'CASCADE') { + // Marked before descending: a row that is already marked cannot be selected again, which is + // what stops a cyclic or self-referencing schema from recursing forever. + updateRows(conn, fk.child, 'sql_deleted = 1', affected); + propagate(conn, fk.child, affected, depth + 1); + continue; + } + // SET NULL / SET DEFAULT: the child survives, so there is nothing to descend into. + const setClause = fk.from + .map((column) => { + const value = fk.onDelete === 'SET NULL' ? 'NULL' : columnDefault(conn, fk.child, column); + return `${ident(column)} = ${value}`; + }) + .join(', '); + updateRows(conn, fk.child, setClause, affected); + } +} + +function equalNames(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Run the propagation for a DELETE that is about to be rewritten. Called with the statement's own + * WHERE clause and bind values, before the parent is marked, so the rows it selects are the ones + * the rewritten UPDATE will mark. + */ +export function cascadeSoftDelete(conn: Connection, table: string, whereClause: string, values: any[]): void { + if (conn.foreignKeyGraph.length === 0) return; + const name = resolveTableName(table); + const roots = rowidsOf(conn, name, `(${whereClause}) AND sql_deleted = 0`, values); + propagate(conn, name, roots, 0); +} diff --git a/src/web/worker/engine.ts b/src/web/worker/engine.ts new file mode 100644 index 00000000..78e2acc3 --- /dev/null +++ b/src/web/worker/engine.ts @@ -0,0 +1,341 @@ +/** + * Thin wrapper over sqlite-wasm's oo1 API. + * + * The semantics are the ones `electron/src/electron-utils/utilsSQLite.ts` (MIT, this repo) + * defines over better-sqlite3: `changes` is a `total_changes()` delta, `lastId` comes from + * `last_insert_rowid()`, and `execute` runs a whole batch. Two deliberate differences: + * + * - changes and lastId are read inside the same worker op as the statement, so nothing can + * interleave between the write and the read. + * - RETURNING is executed directly. better-sqlite3 cannot step a RETURNING statement with + * `.run()`, which is why the electron port strips the clause and re-selects by rowid range; + * sqlite-wasm has no such limit, so the rows come straight from the statement that produced + * them and the rowid-range race disappears. + */ +import { prefixed } from '../errors'; +import type { ExecResult } from '../protocol'; + +import type { ForeignKey } from './cascade'; +import { cascadeSoftDelete, foreignKeys, resolveTableName } from './cascade'; +import { + extractTableName, + extractWhereClause, + producesRows, + replaceUndefinedByNull, + quoteIdent, + softDeleteRewrite, + splitStatements, + statementKind, + stripNoise, +} from './statements'; + +/** Statement kinds that can change the schema, and therefore every cached answer about it. */ +const DDL = new Set(['CREATE', 'DROP', 'ALTER']); + +export class Connection { + private transactionActive = false; + /** + * Whether this database participates in the soft-delete protocol, cached because answering it + * means a PRAGMA per table. Invalidated whenever DDL runs, since that is the only thing that + * can add or remove the `last_modified` / `sql_deleted` pair. + */ + private syncEnabledCache: boolean | null = null; + /** The foreign-key graph, cached for the same reason and invalidated at the same points. */ + private foreignKeyCache: ForeignKey[] | null = null; + /** Per-table answer to "does a DELETE here get recorded", same invalidation again. */ + private softDeleteCache = new Map(); + + constructor( + readonly storage: string, + readonly isReadonly: boolean, + private readonly db: any, + private readonly sqlite3: any, + ) {} + + get pointer(): number { + return this.db.pointer; + } + + /** The oo1 handle. Needed by the tier 2 deserialize path; nothing else should reach for it. */ + get raw(): any { + return this.db; + } + + get isOpen(): boolean { + return !!this.db.pointer; + } + + close(): void { + this.db.close(); + } + + private totalChanges(): number { + return this.db.changes(true, false); + } + + private lastInsertRowid(): number { + return Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)); + } + + userVersion(): number { + return Number(this.db.selectValue('PRAGMA user_version') ?? 0); + } + + setUserVersion(version: number): void { + this.db.exec(`PRAGMA user_version = ${Math.trunc(version)}`); + } + + /** + * Tier 2 opens `:memory:` with create flags because a deserialize needs a writable handle, so + * a read-only connection is enforced here instead of by the open flags. Tier 1 gets the real + * thing from the 'r' flag and does not need this. + */ + setQueryOnly(on: boolean): void { + this.db.exec(`PRAGMA query_only = ${on ? 'ON' : 'OFF'}`); + } + + setForeignKeyConstraintsEnabled(enabled: boolean): void { + this.db.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`); + } + + /** + * Gate for the DELETE rewrite. The port source keys off the columns rather than the presence + * of `sync_table`, so a database gains soft-delete behaviour as soon as its schema declares + * the pair, which is also when `createSyncTable` will accept it. + */ + get syncEnabled(): boolean { + if (this.syncEnabledCache === null) { + this.syncEnabledCache = hasSyncColumns(this); + } + return this.syncEnabledCache; + } + + /** Every foreign key in the database, for the soft-delete cascade. Same caching as syncEnabled. */ + get foreignKeyGraph(): ForeignKey[] { + if (this.foreignKeyCache === null) this.foreignKeyCache = foreignKeys(this); + return this.foreignKeyCache; + } + + /** + * Whether a DELETE against this particular table should be recorded rather than performed. + * + * `syncEnabled` is a property of the database, because that is what the port source checks, but + * it cannot be the whole answer: a schema where one table is sync-tracked and another is not is + * legal, and rewriting a DELETE against the second produces `SET sql_deleted = 1` on a table + * with no such column. The port source has the same hole; here the target table has to carry + * the column too. + */ + softDeletes(table: string | null): boolean { + if (!table || !this.syncEnabled) return false; + const key = table.toLowerCase(); + let known = this.softDeleteCache.get(key); + if (known === undefined) { + try { + // The name arrives as the caller wrote it, which may already be quoted or qualified. + const bare = resolveTableName(table); + known = this.query(`PRAGMA table_info(${quoteIdent(bare)})`).some((row: any) => row.name === 'sql_deleted'); + } catch { + // A name this cannot resolve. Fall back to the database-wide answer, which is what this + // did before the gate existed, rather than failing a delete that used to work. + known = true; + } + this.softDeleteCache.set(key, known); + } + return known; + } + + invalidateSyncCache(): void { + this.syncEnabledCache = null; + this.foreignKeyCache = null; + this.softDeleteCache.clear(); + } + + /** + * A batch of raw statements, as `execute()` receives it. + * + * A DELETE in a batch is soft-deleted exactly as one passed to `run` would be. The port source + * does this too (`utilsSQLite.statementsToSQL92` routes every DELETE through `deleteSQL`), and + * without it `execute('DELETE FROM t WHERE ...')` physically removes rows from a sync-tracked + * database while `run` of the same statement records them, so which entry point the app happened + * to use decides whether the server ever hears about the deletion. + * + * The batch is only split when it needs to be. Handing the whole string to sqlite in one call is + * both faster and less exposed to the splitter, so that stays the path for everything else. + */ + executeBatch(statements: string): ExecResult { + this.invalidateSyncCache(); + const before = this.totalChanges(); + try { + // Cheap test first: `syncEnabled` costs a PRAGMA per table, and the overwhelming majority + // of batches are schema and inserts with no DELETE in them at all. + if (/\bDELETE\b/i.test(stripNoise(statements)) && this.syncEnabled) { + for (const statement of splitStatements(statements)) { + if (statementKind(statement) === 'DELETE') this.run(statement, [], false); + else this.db.exec(statement); + } + } else { + this.db.exec(statements); + } + } catch (err) { + throw prefixed('Execute', err); + } + return { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; + } + + /** + * One statement with its bind values. `wantRows` collects RETURNING / SELECT output. + * + * `rewriteDeletes` is the equivalent of the port source's `fromJson` flag. A DELETE the + * plugin generates itself must run as a real delete: `deleteExportedRows` exists precisely to + * reclaim soft-deleted rows, and a JSON import replicating a server-side deletion has already + * been told the row is gone. Rewriting those would make both operations no-ops. + */ + run(rawStatement: string, values: any[] | undefined, wantRows: boolean, rewriteDeletes = true): ExecResult { + const bind = replaceUndefinedByNull(values); + const isDelete = rewriteDeletes && statementKind(rawStatement) === 'DELETE'; + const table = isDelete ? extractTableName(rawStatement) : null; + const soft = isDelete && this.softDeletes(table); + const statement = soft ? softDeleteRewrite(rawStatement, true) : rawStatement; + + const before = this.totalChanges(); + const exec = () => { + // The cascade runs inside the changes window on purpose: a real DELETE with ON DELETE + // CASCADE counts the rows its actions touched in total_changes, and a soft delete of the + // same rows should not report a different number just because the deletion is being + // recorded rather than performed. + if (soft && table) { + const where = extractWhereClause(rawStatement); + if (where) cascadeSoftDelete(this, table, where.endsWith(';') ? where.slice(0, -1) : where, bind); + } + let rows: any[] | undefined; + try { + if (wantRows) { + rows = this.db.exec({ + sql: statement, + bind: bind.length > 0 ? bind : undefined, + rowMode: 'object', + returnValue: 'resultRows', + }); + } else { + this.db.exec({ sql: statement, bind: bind.length > 0 ? bind : undefined }); + } + } catch (err) { + throw prefixed('Run', err); + } + return rows; + }; + + // A soft delete is a tree of UPDATEs, so it has to be all or nothing: a RESTRICT discovered + // three levels down, or any other failure mid-walk, must not leave half the subtree marked. + // withOptionalTransaction is a no-op when the caller already opened one. + const rows = soft ? this.withOptionalTransaction(true, exec) : exec(); + + // DDL does not only arrive through execute(). A CREATE TABLE issued with run() used to leave + // the cached schema answers in place, so a foreign key added that way was invisible to the + // next cascade and its children were never marked: exactly the silent divergence the cascade + // exists to prevent. + if (DDL.has(statementKind(rawStatement))) this.invalidateSyncCache(); + + const result: ExecResult = { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; + if (rows !== undefined) result.values = rows; + return result; + } + + query(statement: string, values?: any[]): any[] { + const bind = replaceUndefinedByNull(values); + try { + return this.db.exec({ + sql: statement, + bind: bind.length > 0 ? bind : undefined, + rowMode: 'object', + returnValue: 'resultRows', + }); + } catch (err) { + throw prefixed('Query', err); + } + } + + beginTransaction(): void { + if (this.transactionActive) throw new Error('a transaction is already active'); + this.db.exec('BEGIN TRANSACTION;'); + this.transactionActive = true; + } + + commitTransaction(): void { + if (!this.transactionActive) throw new Error('no transaction is active'); + this.db.exec('COMMIT TRANSACTION;'); + this.transactionActive = false; + } + + rollbackTransaction(): void { + if (!this.transactionActive) throw new Error('no transaction is active'); + this.db.exec('ROLLBACK TRANSACTION;'); + this.transactionActive = false; + } + + get isTransactionActive(): boolean { + return this.transactionActive; + } + + /** + * Run `body` inside a transaction the caller asked for, but never nest: `execute`/`run` default + * to transaction=true and are routinely called from inside an explicit beginTransaction(). + */ + withOptionalTransaction(wanted: boolean, body: () => T): T { + const own = wanted && !this.transactionActive; + if (!own) return body(); + this.beginTransaction(); + try { + const out = body(); + this.commitTransaction(); + return out; + } catch (err) { + try { + this.rollbackTransaction(); + } catch { + this.transactionActive = false; + } + throw err; + } + } + + tableList(): any[] { + return this.query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).map((row: any) => row.name); + } + + tableExists(table: string): boolean { + const found = this.db.selectValue("SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?", [table]); + return Number(found ?? 0) > 0; + } + + /** Whole-file image, used by tier 2 flushes and by exportDb. */ + serialize(): Uint8Array { + return this.sqlite3.capi.sqlite3_js_db_export(this.db.pointer); + } +} + +/** Statement-level helper shared by run/executeSet so both decide row collection identically. */ +export function wantsRows(statement: string, returnMode: string | undefined): boolean { + if (returnMode === 'all' || returnMode === 'one') return producesRows(statement); + return false; +} + +/** + * Local copy of the sync-column probe. It lives here rather than in `sync.ts` to avoid a + * circular import: `sync.ts` needs Connection, and Connection needs this answer. + */ +function hasSyncColumns(conn: Connection): boolean { + let lastModified = false; + let sqlDeleted = false; + for (const table of conn.tableList()) { + if (table === 'sync_table') continue; + for (const row of conn.query(`PRAGMA table_info(${quoteIdent(table)})`)) { + if (row.name === 'last_modified') lastModified = true; + if (row.name === 'sql_deleted') sqlDeleted = true; + } + if (lastModified && sqlDeleted) return true; + } + return false; +} diff --git a/src/web/worker/images.ts b/src/web/worker/images.ts new file mode 100644 index 00000000..d2ee59ef --- /dev/null +++ b/src/web/worker/images.ts @@ -0,0 +1,132 @@ +/** + * Tier 2 persistence: whole-database images in IndexedDB. + * + * This is jeep-sqlite's durability model, reimplemented on the one engine: the database lives in + * `:memory:` and the bytes rest in IndexedDB, written at the flush points the plugin already + * documents (close, closeConnection, saveToStore). The image format is plain SQLite, byte for + * byte the same thing tier 1 stores in the pool, which is what lets a database move between the + * tiers and what the M3 jeep migration will rely on. + * + * Raw IndexedDB on purpose: localforage is not a dependency and will not become one. + */ +import { IMAGE_STORE_NAME, IMAGE_STORE_VERSION, META_STORE_NAME, imageStoreDbName } from '../protocol'; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +/** + * Wait for the transaction, not just the request. A write request fires `success` well before its + * transaction commits, and the commit can still fail (quota, eviction, a force-closed connection), + * so resolving on the request alone reports durable data that was never written. + */ +function committed(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + }); +} + +export class ImageStore { + private dbPromise: Promise | null = null; + private name: string; + + constructor(poolName: string) { + this.name = imageStoreDbName(poolName); + } + + private open(): Promise { + if (!this.dbPromise) { + const name = this.name; + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(name, IMAGE_STORE_VERSION); + req.onupgradeneeded = () => { + for (const store of [IMAGE_STORE_NAME, META_STORE_NAME]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('Could not open the image store')); + }); + } + return this.dbPromise; + } + + private async tx(mode: IDBTransactionMode, store: string = IMAGE_STORE_NAME): Promise { + const db = await this.open(); + return db.transaction(store, mode).objectStore(store); + } + + /** A write, waited on all the way to the commit. */ + private async write(store: string, body: (target: IDBObjectStore) => IDBRequest): Promise { + const target = await this.tx('readwrite', store); + const done = committed(target.transaction); + await request(body(target)); + await done; + } + + async get(storage: string): Promise { + const store = await this.tx('readonly'); + const value = await request(store.get(storage)); + if (!value) return null; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return null; + } + + async put(storage: string, image: Uint8Array): Promise { + await this.write(IMAGE_STORE_NAME, (store) => store.put(image, storage)); + } + + async delete(storage: string): Promise { + await this.write(IMAGE_STORE_NAME, (store) => store.delete(storage)); + } + + async has(storage: string): Promise { + const store = await this.tx('readonly'); + const count = await request(store.count(storage)); + return count > 0; + } + + async keys(): Promise { + const store = await this.tx('readonly'); + const keys = await request(store.getAllKeys()); + return keys.map((k) => String(k)); + } + + /** Bookkeeping that is not a database image. Kept out of the image store so it cannot be listed. */ + async getMeta(key: string): Promise { + const store = await this.tx('readonly', META_STORE_NAME); + const value = await request(store.get(key)); + return value === undefined ? null : (value as T); + } + + async setMeta(key: string, value: unknown): Promise { + await this.write(META_STORE_NAME, (store) => store.put(value, key)); + } +} + +/** + * Load an image into an open `:memory:` database. + * + * FREEONCLOSE hands the buffer's lifetime to sqlite (it was allocated with sqlite's allocator by + * allocFromTypedArray), and RESIZEABLE is what allows writes that grow the database past the + * size of the image it was restored from. + */ +export function deserializeInto(sqlite3: any, db: any, image: Uint8Array): void { + const capi = sqlite3.capi; + const pData = sqlite3.wasm.allocFromTypedArray(image); + const rc = capi.sqlite3_deserialize( + db.pointer, + 'main', + pData, + image.byteLength, + image.byteLength, + capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_RESIZEABLE, + ); + db.checkRc(rc); +} diff --git a/src/web/worker/json/export.ts b/src/web/worker/json/export.ts new file mode 100644 index 00000000..a8895c59 --- /dev/null +++ b/src/web/worker/json/export.ts @@ -0,0 +1,290 @@ +/** + * exportToJson, ported from `electron/src/electron-utils/ImportExportJson/exportToJson.ts` + * (MIT, this repo), cross-checked against jeep-sqlite (MIT). + * + * One behaviour is web-specific and deliberate (PLAN 6.3 / M0 finding S3): sqlite-wasm returns + * int64 values above 2^53 as `BigInt`, and `JSON.stringify` throws `TypeError` on those. Since + * the whole point of this method is to hand the caller something they will stringify, every + * value is passed through `jsonSafeValue` on the way out. Safe integers stay numbers; anything + * that a double cannot hold exactly becomes its decimal string, which SQLite's INTEGER affinity + * converts back to the identical int64 on import. Converting to Number instead would silently + * corrupt exactly the values BigInt exists to protect. + */ +import type { JsonColumn, JsonIndex, JsonSQLite, JsonTable, JsonTrigger, JsonView } from '../../../definitions'; +import type { Connection } from '../engine'; + +import { exportableTables, tableColumnNamesTypes } from './schema'; +import { checkIndexesValidity, checkSchemaValidity, checkTriggersValidity } from './validate'; + +export type ProgressFn = (message: string) => void; + +/** JSON cannot carry BigInt. Keep the value, change the carrier. */ +export function jsonSafeValue(value: any): any { + if (typeof value !== 'bigint') return value; + return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value.toString(); +} + +/** + * Commas inside nested parentheses are not column separators. Replacing them with a sentinel + * before splitting, then restoring them, is the port source's approach and it is kept because + * the resulting schema strings have to match what the format already documents. + */ +function maskNestedCommas(input: string): string { + let depth = 0; + let out = ''; + for (const ch of input) { + if (ch === '(') depth++; + else if (ch === ')') depth--; + out += ch === ',' && depth > 0 ? '§' : ch; + } + return out; +} + +export function parseSchema(createSql: string): JsonColumn[] { + const open = createSql.indexOf('('); + const close = createSql.lastIndexOf(')'); + const body = maskNestedCommas(createSql.substring(open + 1, close)); + const schema: JsonColumn[] = []; + + for (const part of body.split(',')) { + const text = part.replace(/\n/g, ' ').trim(); + if (text.length === 0) continue; + const firstWord = text.substring(0, text.indexOf(' ') === -1 ? text.length : text.indexOf(' ')); + const rest = text.indexOf(' ') === -1 ? '' : text.substring(text.indexOf(' ') + 1); + const entry: JsonColumn = {} as JsonColumn; + let value = rest; + + switch (firstWord.toUpperCase()) { + case 'FOREIGN': { + const oPar = text.indexOf('('); + const cPar = text.indexOf(')'); + entry.foreignkey = text + .substring(oPar + 1, cPar) + .split('§') + .map((s) => s.trim()) + .join(','); + value = text.substring(cPar + 2); + break; + } + case 'PRIMARY': + case 'UNIQUE': { + const prefix = firstWord.toUpperCase() === 'PRIMARY' ? 'CPK_' : 'CUN_'; + const oPar = text.indexOf('('); + const cPar = text.indexOf(')'); + entry.constraint = + prefix + + text + .substring(oPar + 1, cPar) + .split('§') + .map((s) => s.trim()) + .join('_'); + value = text; + break; + } + case 'CONSTRAINT': { + const trimmed = rest.trim(); + entry.constraint = trimmed.substring(0, trimmed.indexOf(' ')); + value = trimmed.substring(trimmed.indexOf(' ') + 1); + break; + } + default: { + entry.column = firstWord; + break; + } + } + entry.value = value.replace(/§/g, ','); + schema.push(entry); + } + return schema; +} + +export function getIndexes(conn: Connection, table: string): JsonIndex[] { + const rows = conn.query( + "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql NOTNULL", + [table], + ); + return rows.map((row: any) => { + const sql: string = row.sql; + const index: JsonIndex = {} as JsonIndex; + index.name = row.name; + index.value = sql.slice(sql.lastIndexOf('(') + 1, sql.lastIndexOf(')')); + if (sql.includes('UNIQUE')) index.mode = 'UNIQUE'; + return index; + }); +} + +export function getTriggers(conn: Connection, table: string): JsonTrigger[] { + const rows = conn.query( + "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? AND sql NOT NULL", + [table], + ); + return rows.map((row: any) => { + const sql: string = row.sql; + const name: string = row.name; + const afterName = sql.split(name); + if (afterName.length < 2) throw new Error(`GetTriggers: sql split name does not return 2 values`); + if (!afterName[1].includes(table)) throw new Error(`GetTriggers: sql split does not contain ${table}`); + const timeevent = afterName[1].split(table, 1)[0].trim(); + const afterTable = afterName[1].split(`${timeevent} ${table}`); + if (afterTable.length < 2) throw new Error(`GetTriggers: sql split tableName does not return 2 values`); + + const trigger: JsonTrigger = {} as JsonTrigger; + trigger.name = name; + trigger.timeevent = timeevent; + const tail = afterTable[1].trim(); + if (tail.substring(0, 5).toUpperCase() !== 'BEGIN') { + const parts = tail.split('BEGIN'); + if (parts.length < 2) throw new Error(`GetTriggers: sql split BEGIN does not return 2 values`); + trigger.condition = parts[0].trim(); + trigger.logic = 'BEGIN' + parts.slice(1).join('BEGIN'); + } else { + trigger.logic = tail; + } + return trigger; + }); +} + +/** Rows as arrays in declared column order, which is the shape the JSON format uses. */ +export function getValues(conn: Connection, query: string, table: string, bind: any[] = []): any[][] { + const { names } = tableColumnNamesTypes(conn, table); + if (names.length === 0) throw new Error(`GetValues: Table ${table} no names`); + return conn + .query(query, bind) + .map((row: any) => names.map((name) => (Object.keys(row).includes(name) ? jsonSafeValue(row[name]) : 'NULL'))); +} + +export function getViews(conn: Connection): JsonView[] { + return conn + .query("SELECT name, sql FROM sqlite_master WHERE type = 'view' AND name NOT LIKE 'sqlite_%'") + .map((row: any) => ({ name: row.name, value: row.sql.substring(row.sql.indexOf('AS ') + 3) })); +} + +export function getSyncDate(conn: Connection): number { + const rows = conn.query('SELECT sync_date FROM sync_table WHERE id = ?', [1]); + if (rows.length === 0) throw new Error('GetSyncDate: no syncDate'); + return Number(rows[0][Object.keys(rows[0])[0]]); +} + +export function getLastExportDate(conn: Connection): number { + const rows = conn.query('SELECT sync_date FROM sync_table WHERE id = ?', [2]); + if (rows.length === 0) return -1; + return Number(rows[0][Object.keys(rows[0])[0]]); +} + +export function setLastExportDate(conn: Connection, isoDate: string): void { + if (!conn.tableExists('sync_table')) throw new Error('SetLastExportDate: No sync_table available'); + const seconds = Math.round(new Date(isoDate).getTime() / 1000); + const statement = + getLastExportDate(conn) > 0 + ? `UPDATE sync_table SET sync_date = ${seconds} WHERE id = 2;` + : `INSERT INTO sync_table (sync_date) VALUES (${seconds});`; + conn.executeBatch(statement); +} + +function buildTable( + conn: Connection, + name: string, + createSql: string, + withSchema: boolean, + query: string, + bind: any[], +): JsonTable { + const table: JsonTable = {} as JsonTable; + table.name = name; + + if (withSchema) { + const schema = parseSchema(createSql); + if (schema.length === 0) throw new Error(`GetTables: no Schema returned for ${name}`); + checkSchemaValidity(schema); + table.schema = schema; + + const indexes = getIndexes(conn, name); + if (indexes.length > 0) { + checkIndexesValidity(indexes); + table.indexes = indexes; + } + const triggers = getTriggers(conn, name); + if (triggers.length > 0) { + checkTriggersValidity(triggers); + table.triggers = triggers; + } + } + + const values = getValues(conn, query, name, bind); + if (values.length > 0) table.values = values; + return table; +} + +function getTablesFull(conn: Connection, tables: { name: string; sql: string }[], progress: ProgressFn): JsonTable[] { + const out: JsonTable[] = []; + for (const rTable of tables) { + if (!rTable.name) throw new Error('GetTablesFull: no name'); + if (!rTable.sql) throw new Error('GetTablesFull: no sql'); + out.push(buildTable(conn, rTable.name, rTable.sql, true, `SELECT * FROM ${rTable.name};`, [])); + progress(`Table ${rTable.name} exported`); + } + return out; +} + +/** + * Partial mode exports only what changed since the stored sync date. A table whose rows have + * ALL changed is treated as new and exported with its schema; a partially changed table ships + * rows only; an unchanged table is skipped. + */ +function getTablesPartial( + conn: Connection, + tables: { name: string; sql: string }[], + progress: ProgressFn, +): JsonTable[] { + const syncDate = getSyncDate(conn); + if (syncDate <= 0) throw new Error('GetPartialModeData: no syncDate'); + + const out: JsonTable[] = []; + for (const rTable of tables) { + const total = Number(conn.query(`SELECT count(*) AS n FROM ${rTable.name}`)[0].n); + const modified = Number( + conn.query(`SELECT count(*) AS n FROM ${rTable.name} WHERE last_modified > ?`, [syncDate])[0].n, + ); + if (modified === 0) continue; + const isCreate = total === modified; + const query = isCreate + ? `SELECT * FROM ${rTable.name};` + : `SELECT * FROM ${rTable.name} WHERE last_modified > ${syncDate};`; + out.push(buildTable(conn, rTable.name, rTable.sql, isCreate, query, [])); + progress(`Table ${rTable.name} exported`); + } + return out; +} + +export function exportJson(conn: Connection, database: string, mode: string, progress: ProgressFn): JsonSQLite { + const hasSyncTable = conn.tableExists('sync_table'); + if (hasSyncTable) { + setLastExportDate(conn, new Date().toISOString()); + } else if (mode === 'partial') { + throw new Error('ExportToJson: No sync_table available'); + } + + progress('Start creating the export object'); + const views = getViews(conn); + const resTables = exportableTables(conn); + if (resTables.length === 0) throw new Error("ExportToJson: table's names failed"); + + let tables: JsonTable[]; + if (mode === 'partial') tables = getTablesPartial(conn, resTables, progress); + else if (mode === 'full') tables = getTablesFull(conn, resTables, progress); + else throw new Error(`ExportToJson: expMode ${mode} not defined`); + + const out: JsonSQLite = {} as JsonSQLite; + if (tables.length > 0) { + out.database = database; + out.version = conn.userVersion(); + out.encrypted = false; + out.mode = mode; + out.tables = tables; + if (views.length > 0) out.views = views; + } + progress(`Export object created, ${tables.length} table(s)`); + return out; +} diff --git a/src/web/worker/json/import.ts b/src/web/worker/json/import.ts new file mode 100644 index 00000000..d5f32f45 --- /dev/null +++ b/src/web/worker/json/import.ts @@ -0,0 +1,164 @@ +/** + * importFromJson, ported from `electron/src/electron-utils/ImportExportJson/importFromJson.ts` + * and `utilsJson.ts` (MIT, this repo), cross-checked against jeep-sqlite (MIT). + * + * Row semantics carried over verbatim, because they are the format's contract: + * + * - `mode: 'full'` drops every table first and inserts; `mode: 'partial'` inserts rows whose + * first-column key is absent and updates the rest. + * - In partial mode a row whose `sql_deleted` column is 1 becomes a real DELETE, which is how a + * soft delete replicates from the server into a client database. + * - An UPDATE is skipped entirely when the stored row already matches, so importing the same + * payload twice reports no changes rather than churning `last_modified`. + * - A value that arrives as an array of numbers is a BLOB and is converted back to Uint8Array. + */ +import type { JsonSQLite, JsonTable } from '../../../definitions'; +import { EV_IMPORT_PROGRESS } from '../../protocol'; +import type { Connection } from '../engine'; + +import { createSchema, createViews, tableColumnNamesTypes } from './schema'; + +export type ProgressFn = (message: string) => void; + +/** Drop everything a `mode: 'full'` import is about to recreate. */ +function dropAll(conn: Connection): void { + const drops: string[] = []; + for (const type of ['table', 'index', 'trigger', 'view']) { + const extra = type === 'table' ? " AND name NOT IN ('sync_table')" : ''; + const rows = conn.query( + `SELECT name FROM sqlite_master WHERE type = '${type}' AND name NOT LIKE 'sqlite_%'${extra}`, + ); + for (const row of rows) drops.push(`DROP ${type.toUpperCase()} IF EXISTS ${row.name};`); + } + if (drops.length > 0) conn.executeBatch(drops.join('\n')); + conn.executeBatch('VACUUM;'); +} + +/** An array of plain numbers in a value row is a BLOB in transit. */ +export function reviveRowBlobs(row: any[]): any[] { + return row.map((value) => + Array.isArray(value) && value.every((item: any) => typeof item === 'number') ? Uint8Array.from(value) : value, + ); +} + +function quoteKey(value: any): string { + return typeof value === 'string' ? `'${String(value).replace(/'/g, "''")}'` : `${value}`; +} + +function keyExists(conn: Connection, table: string, keyColumn: string, key: any): boolean { + const rows = conn.query(`SELECT ${keyColumn} FROM ${table} WHERE ${keyColumn} = ?`, [key]); + return rows.length === 1; +} + +/** INSERT, UPDATE or DELETE for one value row, decided the way the port source decides it. */ +export function rowStatement( + conn: Connection, + columnNames: string[], + row: any[], + rowIndex: number, + table: string, + mode: string, +): string { + if (row.length !== columnNames.length || row.length === 0) { + throw new Error(`CreateRowStatement: Table ${table} values row ${rowIndex} not correct length`); + } + const exists = keyExists(conn, table, columnNames[0], row[0]); + + if (mode === 'full' || (mode === 'partial' && !exists)) { + const marks = columnNames.map(() => '?').join(','); + return `INSERT INTO ${table} (${columnNames.join(',')}) VALUES (${marks});`; + } + + const deletedIndex = columnNames.indexOf('sql_deleted'); + if (deletedIndex >= 0 && row[deletedIndex] === 1) { + return `DELETE FROM ${table} WHERE ${columnNames[0]} = ${quoteKey(row[0])};`; + } + const setClause = columnNames.map((name) => `${name} = ?`).join(' ,'); + return `UPDATE ${table} SET ${setClause} WHERE ${columnNames[0]} = ${quoteKey(row[0])};`; +} + +/** Read the stored row back in column order, so an UPDATE can be skipped when nothing changed. */ +function storedRow(conn: Connection, table: string, columnNames: string[], key: any): any[] { + const rows = conn.query(`SELECT * FROM ${table} WHERE ${columnNames[0]} = ?`, [key]); + if (rows.length === 0) return []; + return columnNames.map((name) => (Object.keys(rows[0]).includes(name) ? rows[0][name] : 'NULL')); +} + +function sameValues(a: any[], b: any[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const left = a[i]; + const right = b[i]; + if (left instanceof Uint8Array && right instanceof Uint8Array) { + if (left.length !== right.length) return false; + for (let j = 0; j < left.length; j++) if (left[j] !== right[j]) return false; + continue; + } + // Loose on purpose: an int64 read back as BigInt must compare equal to the number that + // produced it, which is exactly the case `==` handles and `===` does not. + // eslint-disable-next-line eqeqeq + if (left != right) return false; + } + return true; +} + +function createTableData(conn: Connection, table: JsonTable, mode: string, progress: ProgressFn): number { + if (!table.values || table.values.length === 0) return 0; + if (!conn.tableExists(table.name)) { + throw new Error(`CreateDataTable: ${table.name} does not exist`); + } + const { names } = tableColumnNamesTypes(conn, table.name); + if (names.length === 0) throw new Error(`CreateDataTable: ${table.name} info does not exist`); + + let changes = 0; + for (let i = 0; i < table.values.length; i++) { + const row = reviveRowBlobs(table.values[i]); + const statement = rowStatement(conn, names, row, i, table.name, mode); + + if (statement.startsWith('UPDATE')) { + const stored = storedRow(conn, table.name, names, row[0]); + if (stored.length > 0 && sameValues(row, stored)) continue; + } + const bind = statement.startsWith('DELETE') ? [] : row; + // rewriteDeletes = false: a row arriving with sql_deleted = 1 is replicating a deletion that + // already happened upstream, so it must remove the local row rather than re-mark it. + changes += conn.run(statement, bind, false, false).changes; + } + progress.call(null, `Table ${table.name} data imported`); + return changes; +} + +export function importJson(conn: Connection, jsonData: JsonSQLite, progress: ProgressFn): number { + let changes = 0; + conn.setForeignKeyConstraintsEnabled(false); + try { + if (jsonData.tables && jsonData.tables.length > 0) { + conn.setUserVersion(jsonData.version); + if (jsonData.mode === 'full') dropAll(conn); + + progress('Start creating the database schema'); + changes = createSchema(conn, jsonData); + progress(`Schema creation completed changes: ${changes}`); + + progress('Start importing the tables data'); + changes += conn.withOptionalTransaction(true, () => { + let dataChanges = 0; + for (const table of jsonData.tables) { + dataChanges += createTableData(conn, table, jsonData.mode, progress); + } + return dataChanges; + }); + progress(`Tables data import completed changes: ${changes}`); + } + if (jsonData.views && jsonData.views.length > 0) { + progress('Start creating the views'); + changes += createViews(conn, jsonData.views); + progress(`Views creation completed changes: ${changes}`); + } + } finally { + conn.setForeignKeyConstraintsEnabled(true); + } + return changes; +} + +export { EV_IMPORT_PROGRESS }; diff --git a/src/web/worker/json/schema.ts b/src/web/worker/json/schema.ts new file mode 100644 index 00000000..8e12bb5e --- /dev/null +++ b/src/web/worker/json/schema.ts @@ -0,0 +1,112 @@ +/** + * Turning a JsonSQLite object back into DDL, ported from + * `electron/src/electron-utils/ImportExportJson/utilsJson.ts` (MIT, this repo) and + * cross-checked against jeep-sqlite (MIT). + * + * The one piece of behaviour worth knowing about: a table that declares BOTH `last_modified` + * and `sql_deleted` columns gets an automatic `_trigger_last_modified` trigger. That + * pair is what marks a table as participating in the sync protocol, and the same pair is what + * `statements.ts` looks for when deciding to rewrite a DELETE into a soft delete. + */ +import type { JsonSQLite, JsonTable, JsonView } from '../../../definitions'; +import type { Connection } from '../engine'; +import { quoteIdent } from '../statements'; + +export function createSchemaStatements(jsonData: JsonSQLite): string[] { + const statements: string[] = []; + + for (const jTable of jsonData.tables ?? []) { + if (jTable.schema != null && jTable.schema.length >= 1) { + let hasLastModified = false; + let hasSqlDeleted = false; + const columns: string[] = []; + + for (const entry of jTable.schema) { + if (entry.column) { + columns.push(`${entry.column} ${entry.value}`); + if (entry.column === 'last_modified') hasLastModified = true; + if (entry.column === 'sql_deleted') hasSqlDeleted = true; + } else if (entry.foreignkey) { + columns.push(`FOREIGN KEY (${entry.foreignkey}) ${entry.value}`); + } else if (entry.constraint) { + columns.push(`CONSTRAINT ${entry.constraint} ${entry.value}`); + } + } + statements.push(`CREATE TABLE IF NOT EXISTS ${jTable.name} (${columns.join(',')});`); + + if (hasLastModified && hasSqlDeleted) { + statements.push( + `CREATE TRIGGER IF NOT EXISTS ${jTable.name}_trigger_last_modified ` + + `AFTER UPDATE ON ${jTable.name} ` + + `FOR EACH ROW WHEN NEW.last_modified < OLD.last_modified BEGIN ` + + `UPDATE ${jTable.name} SET last_modified = (strftime('%s','now')) WHERE id=OLD.id; END;`, + ); + } + } + + for (const jIndex of jTable.indexes ?? []) { + const mode = jIndex.mode ? `${jIndex.mode} ` : ''; + statements.push(`CREATE ${mode}INDEX IF NOT EXISTS ${jIndex.name} ON ${jTable.name} (${jIndex.value});`); + } + + for (const jTrigger of jTable.triggers ?? []) { + let timeevent = jTrigger.timeevent; + if (timeevent.toUpperCase().endsWith(' ON')) timeevent = timeevent.substring(0, timeevent.length - 3); + const condition = jTrigger.condition ? `${jTrigger.condition} ` : ''; + statements.push( + `CREATE TRIGGER IF NOT EXISTS ${jTrigger.name} ${timeevent} ON ${jTable.name} ${condition}${jTrigger.logic};`, + ); + } + } + + return statements; +} + +export function createSchema(conn: Connection, jsonData: JsonSQLite): number { + const statements = createSchemaStatements(jsonData); + if (statements.length === 0) return 0; + return conn.withOptionalTransaction(true, () => conn.executeBatch(statements.join('\n')).changes); +} + +export function createViews(conn: Connection, views: JsonView[]): number { + if (!views || views.length === 0) return 0; + return conn.withOptionalTransaction(true, () => { + let changes = 0; + for (const view of views) { + if (view.value == null) continue; + changes += conn.executeBatch(`CREATE VIEW IF NOT EXISTS ${view.name} AS ${view.value};`).changes; + } + return changes; + }); +} + +/** Column names and declared types for a table, as PRAGMA table_info reports them. */ +export function tableColumnNamesTypes(conn: Connection, table: string): { names: string[]; types: string[] } { + const rows = conn.query(`PRAGMA table_info(${quoteIdent(table)})`); + return { + names: rows.map((row: any) => row.name), + types: rows.map((row: any) => row.type), + }; +} + +export function tableExists(conn: Connection, table: string): boolean { + return conn.tableExists(table); +} + +export function tableNames(conn: Connection): string[] { + return conn + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .map((row: any) => row.name); +} + +/** Tables eligible for export: user tables, excluding sync bookkeeping and temporaries. */ +export function exportableTables(conn: Connection): { name: string; sql: string }[] { + return conn.query( + "SELECT name, sql FROM sqlite_master WHERE type = 'table' " + + "AND name NOT LIKE 'sync_table' AND name NOT LIKE '_temp_%' AND name NOT LIKE 'sqlite_%'", + ) as { name: string; sql: string }[]; +} + +export function tableFromName(jsonData: JsonSQLite, name: string): JsonTable | undefined { + return (jsonData.tables ?? []).find((t) => t.name === name); +} diff --git a/src/web/worker/json/validate.ts b/src/web/worker/json/validate.ts new file mode 100644 index 00000000..bd653b41 --- /dev/null +++ b/src/web/worker/json/validate.ts @@ -0,0 +1,190 @@ +/** + * JSON shape validation, ported from + * `electron/src/electron-utils/ImportExportJson/utilsJson.ts` (MIT, this repo), cross-checked + * against jeep-sqlite (MIT), which carries the same logic under the same names. + * + * The rules are deliberately conservative and key-exact: an unknown key anywhere in the object + * makes the whole thing invalid. `isJsonValid` is a public plugin method, so loosening this + * would change a documented contract, not just an internal check. + * + * The format itself is specified in `docs/ImportExportJson.md`. + */ +import type { JsonColumn, JsonIndex, JsonSQLite, JsonTrigger, JsonView } from '../../../definitions'; + +const FIRST_LEVEL_KEYS = ['database', 'version', 'overwrite', 'encrypted', 'mode', 'tables', 'views']; +const TABLE_KEYS = ['name', 'schema', 'indexes', 'triggers', 'values']; +const SCHEMA_KEYS = ['column', 'value', 'foreignkey', 'primarykey', 'constraint']; +const INDEX_KEYS = ['name', 'value', 'mode']; +const TRIGGER_KEYS = ['name', 'timeevent', 'condition', 'logic']; +const VIEW_KEYS = ['name', 'value']; + +function isEmptyObject(obj: any): boolean { + return obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object); +} + +/** Every key present must be known, and every known key present must have the right type. */ +function keysAreTyped(obj: any, allowed: string[], types: Record boolean>): boolean { + if (isEmptyObject(obj)) return false; + for (const key of Object.keys(obj)) { + if (allowed.indexOf(key) === -1) return false; + const check = types[key]; + if (check && !check(obj[key])) return false; + } + return true; +} + +const isString = (v: any) => typeof v === 'string'; +const isNumber = (v: any) => typeof v === 'number'; +const isBoolean = (v: any) => typeof v === 'boolean'; +const isObject = (v: any) => typeof v === 'object'; + +export function isSchema(obj: any): boolean { + return keysAreTyped(obj, SCHEMA_KEYS, { + column: isString, + value: isString, + foreignkey: isString, + primarykey: isString, + constraint: isString, + }); +} + +export function isIndex(obj: any): boolean { + return keysAreTyped(obj, INDEX_KEYS, { + name: isString, + value: isString, + // The only index modifier the format accepts. + mode: (v: any) => typeof v === 'string' && v.toUpperCase() === 'UNIQUE', + }); +} + +export function isTrigger(obj: any): boolean { + return keysAreTyped(obj, TRIGGER_KEYS, { + name: isString, + timeevent: isString, + condition: isString, + logic: isString, + }); +} + +export function isView(obj: any): boolean { + return keysAreTyped(obj, VIEW_KEYS, { name: isString, value: isString }); +} + +export function isTable(obj: any): boolean { + if ( + !keysAreTyped(obj, TABLE_KEYS, { + name: isString, + schema: isObject, + indexes: isObject, + triggers: isObject, + values: isObject, + }) + ) { + return false; + } + + // Column count comes from the schema and constrains the width of every value row. + let columnCount = 0; + if (obj.schema) { + for (const element of obj.schema) { + if (element?.column) columnCount++; + } + for (let i = 0; i < columnCount; i++) { + if (!isSchema(obj.schema[i])) return false; + } + } + if (obj.indexes) { + for (const index of obj.indexes) if (!isIndex(index)) return false; + } + if (obj.triggers) { + for (const trigger of obj.triggers) if (!isTrigger(trigger)) return false; + } + if (obj.values && columnCount > 0) { + for (const row of obj.values) { + if (typeof row !== 'object' || row.length !== columnCount) return false; + } + } + return true; +} + +export function isJsonSQLite(obj: any): boolean { + if ( + !keysAreTyped(obj, FIRST_LEVEL_KEYS, { + database: isString, + version: isNumber, + overwrite: isBoolean, + encrypted: isBoolean, + mode: isString, + tables: isObject, + views: isObject, + }) + ) { + return false; + } + if (obj.tables) { + for (const table of obj.tables) if (!isTable(table)) return false; + } + if (obj.views) { + for (const view of obj.views) if (!isView(view)) return false; + } + return true; +} + +/** + * The check* helpers rebuild each object from its known keys before validating, so a stray key + * is dropped rather than failing. That asymmetry with isJsonSQLite is intentional and matches + * the port source: these run on data the plugin itself produced during export. + */ +function pick(source: any, keys: string[]): T { + const out: any = {}; + for (const key of keys) { + if (Object.keys(source).includes(key)) out[key] = source[key]; + } + return out as T; +} + +export function checkSchemaValidity(schema: JsonColumn[]): void { + schema.forEach((entry, index) => { + if (!isSchema(pick(entry, ['column', 'value', 'foreignkey', 'constraint']))) { + throw new Error(`CheckSchemaValidity: schema[${index}] not valid`); + } + }); +} + +export function checkIndexesValidity(indexes: JsonIndex[]): void { + indexes.forEach((entry, index) => { + if (!isIndex(pick(entry, ['name', 'value', 'mode']))) { + throw new Error(`CheckIndexesValidity: indexes[${index}] not valid`); + } + }); +} + +export function checkTriggersValidity(triggers: JsonTrigger[]): void { + triggers.forEach((entry, index) => { + if (!isTrigger(pick(entry, ['name', 'timeevent', 'condition', 'logic']))) { + throw new Error(`CheckTriggersValidity: triggers[${index}] not valid`); + } + }); +} + +export function checkViewsValidity(views: JsonView[]): void { + views.forEach((entry, index) => { + if (!isView(pick(entry, ['name', 'value']))) { + throw new Error(`CheckViewsValidity: views[${index}] not valid`); + } + }); +} + +/** Parse and validate in one step, with the error text the plugin has always used. */ +export function parseJsonSQLite(jsonstring: string): JsonSQLite { + let parsed: any; + try { + parsed = JSON.parse(jsonstring); + } catch (err) { + throw new Error(`ImportFromJson: Stringify Json Object not Valid`); + } + if (!isJsonSQLite(parsed)) { + throw new Error(`ImportFromJson: Stringify Json Object not Valid`); + } + return parsed as JsonSQLite; +} diff --git a/src/web/worker/migrate-jeep.ts b/src/web/worker/migrate-jeep.ts new file mode 100644 index 00000000..f5931eeb --- /dev/null +++ b/src/web/worker/migrate-jeep.ts @@ -0,0 +1,367 @@ +/** + * One-time migration out of jeep-sqlite's storage. + * + * jeep-sqlite kept every database as a whole-file image in a localforage store: IndexedDB + * database `jeepSqliteStore`, object store `databases`, out-of-line string keys of the form + * `SQLite.db`, values produced by sql.js's `export()`. This module reads that store with + * raw IndexedDB (localforage is not a dependency and will not become one), hands every image to + * the active tier through the same import path a downloaded `.sqlite` file takes, verifies each + * one with `PRAGMA integrity_check`, and only then retires the legacy store. + * + * The rules below were verified against jeep-sqlite 2.8.0 driven in a real browser rather than + * read off its source, because three of them are invisible in the source: + * + * - The legacy IndexedDB database is at **version 2 with two object stores**, `databases` and + * `local-forage-detect-blob-support`; localforage adds the second one itself. Opening with an + * explicit version 1, which is what jeep's own localforage config asks for, would fail with a + * VersionError, so the open here passes no version at all. + * - A database jeep opened but never saved leaves its key present with an **undefined value** + * (`UtilsStore.setInitialDBToStore` stores `null`, which reads back as `undefined`). Those + * keys are placeholders for a database that never had any content, not data. + * - `backup-SQLite.db` keys are written before a version upgrade and removed after it + * (jeep `utils/database.js`), so an interrupted upgrade leaves one behind. Importing one would + * conjure a phantom database called `backup-`, so it is skipped and a leftover backup + * never blocks retiring the legacy store. Only while `SQLite.db` is also present, though: + * on its own the key is the sole copy of something, quite possibly a database the app really + * did name `backup-...`, and it is then migrated like any other. + * + * Failure policy: anything that goes wrong leaves the legacy store exactly as it was and reports + * a warning. Boot is never blocked, and no legacy byte is deleted until every real database has + * been imported and has passed its integrity check. The run happens at most once either way: a + * second attempt would re-import legacy images over databases the app has been writing to since + * the first, which loses more than it recovers. + */ +import { messageOf } from '../errors'; +import type { JeepMigrationResult } from '../protocol'; + +import type { AdoptionTarget } from './adoption'; +import { looksLikeSQLite } from './adoption'; + +/** localforage `name` and `storeName` from jeep's `setConfig` (jeep-sqlite.js `setConfig`). */ +export const JEEP_DB_NAME = 'jeepSqliteStore'; +export const JEEP_STORE_NAME = 'databases'; + +/** Marker key in the image store's meta store. Its value is the ISO date the migration ran. */ +export const JEEP_MIGRATION_MARKER = 'jeep-migration'; + +const BACKUP_PREFIX = 'backup-'; +const SUFFIX = 'SQLite.db'; +/** How long to wait for a delete that another context is blocking before giving up on it. */ +const DELETE_TIMEOUT_MS = 5000; + +/** Where the marker lives. Implemented by ImageStore on both tiers. */ +export interface MarkerStore { + getMeta(key: string): Promise; + setMeta(key: string, value: unknown): Promise; +} + +/** + * The tier-specific half, shared with the tier-promotion pass. Nothing here ever writes over a + * database that is already in the store: `exists` is consulted first, and a taken name fails that + * database instead. The migration cannot know whether such a database is a leftover of its own or + * one the app has been using, and only one of those two is safe to overwrite. + */ +export type JeepMigrationTarget = AdoptionTarget; + +interface LegacyEntry { + key: string; + value: unknown; +} + +/** + * jeep's own `removePathSuffix`: `fooSQLite.db` -> `foo`, with a secondary plain-`.db` case for + * keys that predate the suffix convention. Kept here rather than in paths.ts because it is + * jeep's rule for jeep's keys, not this plugin's naming. + */ +export function databaseNameFromKey(key: string): string { + if (key.includes(SUFFIX)) return key.split(SUFFIX)[0]; + if (key.endsWith('.db')) return key.slice(0, -3); + return key; +} + +/** + * Values arrive as Uint8Array in practice; the other shapes are localforage driver defence. + * `undefined` means the value could not be decoded at all, which is NOT the same as an empty + * placeholder and must never be mistaken for one: skipping an undecodable value would delete the + * only copy of whatever it was. + */ +async function toBytes(value: unknown): Promise { + if (value === null || value === undefined) return null; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + if (typeof Blob !== 'undefined' && value instanceof Blob) return new Uint8Array(await value.arrayBuffer()); + return undefined; +} + +/** + * True/false when the browser can answer cheaply, null when it cannot. `indexedDB.databases()` + * is absent on Firefox before 126, which is why the caller also has the open-and-detect path. + */ +async function legacyStoreListed(): Promise { + const idb = indexedDB as any; + if (typeof idb.databases !== 'function') return null; + try { + const list: { name?: string }[] = await idb.databases(); + return list.some((entry) => entry?.name === JEEP_DB_NAME); + } catch { + return null; + } +} + +/** + * Open the legacy store without creating one. `indexedDB.open` with no version creates the + * database when it is absent, which would leave a phantom `jeepSqliteStore` behind on every + * fresh install, so a create is detected through `onupgradeneeded` and undone immediately. + */ +function openLegacyStore(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(JEEP_DB_NAME); + let created = false; + req.onupgradeneeded = () => { + created = true; + }; + req.onsuccess = () => { + const db = req.result; + if (created) { + db.close(); + indexedDB.deleteDatabase(JEEP_DB_NAME); + resolve(null); + return; + } + if (!db.objectStoreNames.contains(JEEP_STORE_NAME)) { + db.close(); + resolve(null); + return; + } + resolve(db); + }; + req.onerror = () => reject(req.error ?? new Error(`Could not open ${JEEP_DB_NAME}`)); + }); +} + +function readAll(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const entries: LegacyEntry[] = []; + const cursor = db.transaction(JEEP_STORE_NAME, 'readonly').objectStore(JEEP_STORE_NAME).openCursor(); + cursor.onsuccess = () => { + const at = cursor.result; + if (!at) { + resolve(entries); + return; + } + entries.push({ key: String(at.key), value: at.value }); + at.continue(); + }; + cursor.onerror = () => reject(cursor.error ?? new Error(`Could not read ${JEEP_STORE_NAME}`)); + }); +} + +/** + * Best effort. A delete that another browsing context blocks stays pending indefinitely, so it + * is raced against a timeout: the databases have already been imported and verified by this + * point, and the marker stops the migration running again, so a legacy store left on disk costs + * quota rather than correctness. + */ +function deleteLegacyStore(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: boolean) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const req = indexedDB.deleteDatabase(JEEP_DB_NAME); + req.onsuccess = () => done(true); + req.onerror = () => done(false); + setTimeout(() => done(false), DELETE_TIMEOUT_MS); + }); +} + +function nothingToDo(): JeepMigrationResult { + return { ran: false, migrated: [], skipped: [], failed: [], legacyStoreDeleted: false }; +} + +/** + * Run the migration if it has not run before. Never throws: every failure path returns a result + * carrying a warning, because a store this plugin cannot read is not a reason to refuse to boot. + */ +export async function migrateFromJeep(marker: MarkerStore, target: JeepMigrationTarget): Promise { + try { + if (await marker.getMeta(JEEP_MIGRATION_MARKER)) return nothingToDo(); + } catch { + // An unreadable marker store means the migration cannot be made one-shot, and re-importing + // legacy images over live data on every boot would be worse than not migrating at all. + return { + ...nothingToDo(), + warning: 'Could not read the migration marker, so the jeep-sqlite migration was skipped.', + }; + } + + if ((await legacyStoreListed()) === false) { + await markDone(marker); + return nothingToDo(); + } + + let legacy: IDBDatabase | null = null; + try { + legacy = await openLegacyStore(); + if (!legacy) { + await markDone(marker); + return nothingToDo(); + } + const entries = await readAll(legacy); + legacy.close(); + legacy = null; + return await importEntries(marker, target, entries); + } catch (err) { + legacy?.close(); + return { + ...nothingToDo(), + ran: true, + warning: `The jeep-sqlite store could not be migrated and was left untouched: ${messageOf(err)}`, + }; + } +} + +async function importEntries( + marker: MarkerStore, + target: JeepMigrationTarget, + entries: LegacyEntry[], +): Promise { + const migrated: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + const problems: string[] = []; + + // Classify everything before importing anything, so a collision is caught while the target is + // still untouched. + const candidates: { database: string; storage: string; bytes: Uint8Array }[] = []; + const claimed = new Map(); + const keys = new Set(entries.map((entry) => entry.key)); + + for (const entry of entries) { + // A `backup-x` key is jeep's pre-upgrade copy of `x`, so it is only redundant while `x` is + // actually there. On its own it is the sole copy of something, quite possibly a database the + // app really did call `backup-...`, and skipping it would delete it along with the store. + if (entry.key.startsWith(BACKUP_PREFIX) && keys.has(entry.key.slice(BACKUP_PREFIX.length))) { + skipped.push(entry.key); + continue; + } + const bytes = await toBytes(entry.value); + const database = databaseNameFromKey(entry.key); + const storage = `${database}${SUFFIX}`; + if (bytes === undefined) { + failed.push(database); + problems.push(`${database}: the stored value is not readable as bytes`); + continue; + } + if (bytes === null || bytes.byteLength === 0) { + // jeep's placeholder for a database it created but never saved. There is nothing to import + // and nothing to lose, so it must not stand in the way of retiring the store. + skipped.push(entry.key); + continue; + } + if (!looksLikeSQLite(bytes)) { + failed.push(database); + problems.push(`${database}: the stored value is not a SQLite database image`); + continue; + } + // Two legacy keys can resolve to one database: jeep's own key format is `SQLite.db`, + // but `setPathSuffix` leaves a picked file called `foo` under the bare key `foo`, which names + // the same target as `fooSQLite.db`. Importing both would mean one silently overwriting the + // other and the loser then being deleted along with the legacy store. The second claim fails + // instead, which is enough to keep the store and everything in it. + const other = claimed.get(storage); + if (other !== undefined) { + failed.push(database); + problems.push(`${database}: the keys ${other} and ${entry.key} both claim it`); + continue; + } + claimed.set(storage, entry.key); + candidates.push({ database, storage, bytes }); + } + + for (const { database, storage, bytes } of candidates) { + // Never write over something already in the store. On a normal first boot nothing is there, + // but an app that ran with the migration disabled, or one that crashed mid-migration, can + // leave a database under this name, and importing over it would destroy live data. This is + // checked outside the try on purpose: the recovery below discards the target, which must + // never happen to a database this migration did not create. + let taken: boolean; + try { + taken = await target.exists(storage); + } catch (err) { + failed.push(database); + problems.push(`${database}: ${messageOf(err)}`); + continue; + } + if (taken) { + failed.push(database); + problems.push(`${database}: a database of that name is already in the store`); + continue; + } + + try { + await target.adopt(storage, bytes); + await target.verify(storage); + migrated.push(database); + } catch (err) { + failed.push(database); + problems.push(`${database}: ${messageOf(err)}`); + try { + await target.discard(storage); + } catch { + // Nothing further to do: the legacy image is still there and the store is kept. + } + } + } + + // The marker is set whatever happened, because this ran to completion. Retrying on the next + // boot would mean re-importing legacy images over databases the app has been writing to since, + // which costs more data than the one it would recover. A failure keeps the legacy store, so + // nothing is lost and an app that wants a retry can clear the marker itself. + await markDone(marker); + + if (failed.length > 0) { + return { + ran: true, + migrated, + skipped, + failed, + legacyStoreDeleted: false, + warning: + `The jeep-sqlite store was left in place because ${failed.length} of its databases ` + + `could not be migrated (${problems.join('; ')}). It will not be retried automatically.` + + (migrated.length > 0 ? ` Migrated: ${migrated.join(', ')}.` : ''), + }; + } + + const legacyStoreDeleted = await deleteLegacyStore(); + return { + ran: true, + migrated, + skipped, + failed, + legacyStoreDeleted, + ...(legacyStoreDeleted + ? {} + : { + warning: + 'Every jeep-sqlite database was migrated, but the legacy IndexedDB store could not be ' + + 'deleted (another tab may still hold it open). It is safe to remove by hand.', + }), + }; +} + +async function markDone(marker: MarkerStore): Promise { + try { + await marker.setMeta(JEEP_MIGRATION_MARKER, new Date().toISOString()); + } catch { + // Worst case the probe runs again on the next boot and finds nothing to do. + } +} diff --git a/src/web/worker/paths.ts b/src/web/worker/paths.ts new file mode 100644 index 00000000..dd86a580 --- /dev/null +++ b/src/web/worker/paths.ts @@ -0,0 +1,60 @@ +/** + * Naming rules for stored databases, in one place because both of them bite silently. + * + * 1. The plugin's storage name is the connection name plus the `SQLite.db` suffix, matching + * every native implementation (`electron/src/index.ts:94`, `ios/Plugin/CapacitorSQLite.swift:1157`) + * and what `getDatabaseList()` is documented to return. + * 2. opfs-sahpool registers files under the path its VFS computes + * (`new URL(name, 'file://localhost/').pathname`), so `new OpfsSAHPoolDb('a.db')` maps to + * `/a.db`, but `exportFile` / `importDb` / `unlink` look their argument up RAW. Feeding them + * the unnormalized name throws "File not found" on a database that plainly exists. Note that + * the normalization is a URL pathname, so it also percent-encodes: a database called `存在` + * is registered as `/%E5%AD%98%E5%9C%A8SQLite.db`. Reusing the same URL computation here is + * the only way to stay in step with it. + */ + +const SUFFIX = 'SQLite.db'; + +/** `foo` or `foo.db` or `fooSQLite.db` -> `fooSQLite.db`. */ +export function storageName(database: string): string { + let name = database; + if (name.endsWith(SUFFIX)) return name; + if (name.endsWith('.db')) name = name.slice(0, -3); + return `${name}${SUFFIX}`; +} + +/** `fooSQLite.db` -> `foo`. Inverse of storageName for registry keys and error messages. */ +export function connectionName(storage: string): string { + return storage.endsWith(SUFFIX) ? storage.slice(0, -SUFFIX.length) : storage; +} + +/** The key opfs-sahpool's exportFile / importDb / unlink actually look up. */ +export function poolPath(storage: string): string { + return new URL(storage.replace(/^\/+/, ''), 'file://localhost/').pathname; +} + +/** Inverse of poolPath, for turning `getFileNames()` output back into database names. */ +export function fromPoolPath(path: string): string { + const stripped = path.replace(/^\/+/, ''); + try { + return decodeURIComponent(stripped); + } catch { + return stripped; + } +} + +/** Registry key shared with SQLiteConnection._connectionDict in definitions.ts. */ +export function connKey(database: string, readonly: boolean): string { + return `${readonly ? 'RO' : 'RW'}_${database}`; +} + +/** + * importDb picks a free handle out of the pool and throws "No available handles to import to." + * when there is none. Default initialCapacity is 6, so growing before an import is mandatory. + */ +export async function reserveCapacity(poolUtil: any, extra = 2): Promise { + const needed = poolUtil.getFileCount() + extra; + if (poolUtil.getCapacity() < needed) { + await poolUtil.reserveMinimumCapacity(needed); + } +} diff --git a/src/web/worker/promote.ts b/src/web/worker/promote.ts new file mode 100644 index 00000000..f407b4b4 --- /dev/null +++ b/src/web/worker/promote.ts @@ -0,0 +1,102 @@ +/** + * Tier promotion: moving databases from the IndexedDB image store into the OPFS pool. + * + * The two tiers do not read each other. A browser that lacked OPFS sync access handles stores + * whole-file images in IndexedDB; when the same browser later gains them, tier selection picks + * tier 1 and the pool is empty, so `getDatabaseList()` returns nothing while the user's data sits + * in IndexedDB with no way back. That is not hypothetical: it is the normal upgrade path for + * exactly the population that runs on tier 2 (Android WebView reaching M132, iOS 16.3 to 16.4). + * + * So on every tier 1 init, any image left in the store is adopted into the pool through the same + * verified path the jeep-sqlite migration uses, and only then removed. + * + * Two rules make this safe to run on every boot rather than once: + * + * - **The pool wins a name conflict.** A database can only be in the pool because it was written + * while on tier 1, which is after the flip, which makes it newer than any image left behind. + * The image is NOT deleted in that case: the only way both can exist is a promotion that + * failed or was interrupted and an app that then created the name itself, and in that + * situation the image may be the copy that matters. It is reported and left alone. + * - **An image is deleted only after its own adoption has verified.** Anything not yet promoted + * is therefore still exactly where tier 2 would look for it, so an interrupted run resumes on + * the next boot and a browser that drops back to tier 2 still finds what was not moved. + */ +import { messageOf } from '../errors'; +import type { TierPromotionResult } from '../protocol'; + +import type { AdoptionTarget } from './adoption'; +import { looksLikeSQLite } from './adoption'; + +/** The bit of ImageStore promotion needs. Narrowed so the tests can drive it directly. */ +export interface PromotableImages { + keys(): Promise; + get(storage: string): Promise; + delete(storage: string): Promise; +} + +export function noPromotion(): TierPromotionResult { + return { promoted: [], conflicts: [], failed: [] }; +} + +/** + * Never throws. A store that cannot be promoted is a reason to warn, not a reason to refuse to + * boot: the images are still readable by a tier 2 context and nothing has been destroyed. + */ +export async function promoteImages(images: PromotableImages, target: AdoptionTarget): Promise { + let keys: string[]; + try { + keys = await images.keys(); + } catch (err) { + return { ...noPromotion(), warning: `Could not read the image store: ${messageOf(err)}` }; + } + if (keys.length === 0) return noPromotion(); + + const promoted: string[] = []; + const conflicts: string[] = []; + const failed: string[] = []; + const problems: string[] = []; + + for (const storage of keys) { + try { + if (await target.exists(storage)) { + conflicts.push(storage); + continue; + } + const bytes = await images.get(storage); + if (!bytes || bytes.byteLength === 0 || !looksLikeSQLite(bytes)) { + failed.push(storage); + problems.push(`${storage}: the stored image is not a SQLite database`); + continue; + } + await target.adopt(storage, bytes); + await target.verify(storage); + // Only now, with a readable database in the pool, does the original go. + await images.delete(storage); + promoted.push(storage); + } catch (err) { + failed.push(storage); + problems.push(`${storage}: ${messageOf(err)}`); + try { + await target.discard(storage); + } catch { + // The image is still in the store, which is what matters. + } + } + } + + const warnings: string[] = []; + if (failed.length > 0) { + warnings.push( + `${failed.length} database(s) could not be moved out of the IndexedDB fallback store and were ` + + `left there (${problems.join('; ')}). They will be retried on the next start.`, + ); + } + if (conflicts.length > 0) { + warnings.push( + `${conflicts.length} database(s) in the IndexedDB fallback store share a name with a database ` + + `already in this store and were left untouched (${conflicts.join(', ')}). The one in use is ` + + 'the newer of the two; the older copy is still in IndexedDB if you need it.', + ); + } + return { promoted, conflicts, failed, ...(warnings.length > 0 ? { warning: warnings.join(' ') } : {}) }; +} diff --git a/src/web/worker/statements.ts b/src/web/worker/statements.ts new file mode 100644 index 00000000..ac3edbde --- /dev/null +++ b/src/web/worker/statements.ts @@ -0,0 +1,261 @@ +/** + * Statement splitting and inspection. + * + * Behaviour is the one `electron/src/electron-utils/utilsSQLite.ts` (MIT, this repo) established + * for the batch `execute` path, but the splitting is done with a character scanner rather than + * the string-replace-and-split-on-semicolon approach used there: a semicolon inside a string + * literal, a comment, or a trigger body must not end a statement, and trigger bodies are common + * in this plugin's upgrade statements. + * + * The `sql_deleted` DELETE rewrite that also lives in this layer arrives in M2. + */ + +/** Split a batch into individual statements, ignoring semicolons that are not separators. */ +export function splitStatements(sql: string): string[] { + const out: string[] = []; + let current = ''; + let depth = 0; // nesting of BEGIN ... END inside CREATE TRIGGER bodies + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + + // string literal + if (ch === "'") { + const end = scanQuoted(sql, i, "'"); + current += sql.slice(i, end); + i = end - 1; + continue; + } + // quoted identifiers + if (ch === '"' || ch === '`') { + const end = scanQuoted(sql, i, ch); + current += sql.slice(i, end); + i = end - 1; + continue; + } + if (ch === '[') { + const end = sql.indexOf(']', i + 1); + const stop = end === -1 ? sql.length : end + 1; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + // comments + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + const stop = nl === -1 ? sql.length : nl; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + if (ch === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + const stop = end === -1 ? sql.length : end + 2; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + + if (isWordBoundary(sql, i)) { + if (matchesKeyword(sql, i, 'BEGIN')) depth++; + else if (matchesKeyword(sql, i, 'END')) depth = Math.max(0, depth - 1); + } + + if (ch === ';' && depth === 0) { + push(out, current); + current = ''; + continue; + } + current += ch; + } + push(out, current); + return out; +} + +function push(out: string[], candidate: string): void { + const trimmed = candidate.trim(); + if (trimmed.length > 0 && stripNoise(trimmed).trim().length > 0) out.push(trimmed); +} + +function scanQuoted(sql: string, start: number, quote: string): number { + let i = start + 1; + while (i < sql.length) { + if (sql[i] === quote) { + if (sql[i + 1] === quote) { + i += 2; // doubled quote is an escaped quote + continue; + } + return i + 1; + } + i++; + } + return sql.length; +} + +function isWordBoundary(sql: string, i: number): boolean { + return i === 0 || !/[A-Za-z0-9_]/.test(sql[i - 1]); +} + +function matchesKeyword(sql: string, i: number, keyword: string): boolean { + if (sql.substr(i, keyword.length).toUpperCase() !== keyword) return false; + const after = sql[i + keyword.length]; + return after === undefined || !/[A-Za-z0-9_]/.test(after); +} + +/** + * Blank out string literals and comments so keyword matching cannot be fooled by data. + * + * Length preserving, deliberately: every blanked run becomes the same number of spaces, so an + * offset into the result is an offset into the original. `extractWhereClause` depends on that to + * hand back a clause with its literals intact. Collapsing each run to one space instead, which is + * what this did originally, silently deleted them. + */ +export function stripNoise(sql: string): string { + let out = ''; + const blank = (from: number, to: number) => ' '.repeat(to - from); + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const end = scanQuoted(sql, i, ch); + out += blank(i, end); + i = end - 1; + continue; + } + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + const end = nl === -1 ? sql.length : nl; + out += blank(i, end); + i = end - 1; + continue; + } + if (ch === '/' && sql[i + 1] === '*') { + const found = sql.indexOf('*/', i + 2); + const end = found === -1 ? sql.length : found + 2; + out += blank(i, end); + i = end - 1; + continue; + } + out += ch; + } + return out; +} + +/** + * A table or column name, quoted for interpolation into SQL. + * + * `tableList()` and `PRAGMA foreign_key_list` return bare names, and a schema is free to contain + * a table called `order` or `group`. Interpolating one of those unquoted turns an internal PRAGMA + * into a syntax error, which is how a reserved-word table used to switch the whole soft-delete + * protocol off for a database. + */ +export function quoteIdent(name: string): string { + return `"${String(name).replace(/"/g, '""')}"`; +} + +/** The leading keyword, upper-cased: SELECT, INSERT, UPDATE, DELETE, CREATE, PRAGMA, ... */ +export function statementKind(sql: string): string { + const match = stripNoise(sql) + .trim() + .match(/^([A-Za-z]+)/); + return match ? match[1].toUpperCase() : ''; +} + +/** + * True when the statement carries a RETURNING clause. Unlike better-sqlite3, sqlite-wasm can + * step a RETURNING statement and hand back its rows directly, so the electron workaround of + * stripping the clause and re-selecting by rowid range is not needed here. + */ +export function hasReturningClause(sql: string): boolean { + return /\bRETURNING\b/i.test(stripNoise(sql)); +} + +/** Statements that produce rows, so `exec` knows whether to collect any. */ +export function producesRows(sql: string): boolean { + const kind = statementKind(sql); + if (kind === 'SELECT' || kind === 'WITH' || kind === 'PRAGMA' || kind === 'EXPLAIN') return true; + return hasReturningClause(sql); +} + +/** Ported from electron-utils: bound `undefined` must reach sqlite as NULL, not throw. */ +export function replaceUndefinedByNull(values: any[] | undefined): any[] { + if (!values || values.length === 0) return []; + return values.map((value) => (value === undefined ? null : value)); +} + +/** + * The soft-delete rewrite (PLAN 2.4), ported from `utilsSQLite.deleteSQL` in + * `electron/src/electron-utils/` (MIT, this repo) and cross-checked against jeep-sqlite (MIT), + * which carries the identical logic. + * + * When a database participates in sync (its tables carry both `last_modified` and + * `sql_deleted`), a DELETE is not a delete: the row is marked instead, so the next export can + * tell the server about it. `deleteExportedRows` is what eventually removes it for real. + * + * `AND sql_deleted = 0` on the rewritten statement keeps the operation idempotent: deleting an + * already-soft-deleted row reports zero changes rather than touching `last_modified` again. + */ +export function extractTableName(statement: string): string | null { + const stripped = stripNoise(statement); + // The keyword is located in the blanked copy so a table name inside a literal cannot be picked + // up, but the name itself is read from the original. `stripNoise` blanks double-quoted + // identifiers along with strings, and a greedy `\s+` after the keyword would swallow the blanks + // where the name used to be: that is how `DELETE FROM "order" WHERE id = ?` yielded the table + // name `WHERE`, and a soft delete against a quoted table silently became a real one. + const match = stripped.match(/(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b/i); + if (!match || match.index === undefined) return null; + const after = statement.slice(match.index + match[0].length); + const lead = after.match(/^\s*/)?.[0].length ?? 0; + const token = after.slice(lead).match(/^(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]|[^\s;(]+)/); + return token ? token[0] : null; +} + +/** + * The WHERE clause as the caller wrote it, literals and all. + * + * The keyword hunt runs over the blanked copy so a `WHERE` inside a string cannot be mistaken for + * the real one, but the clause itself is sliced out of the original: returning the blanked text + * would drop every literal, and `DELETE FROM t WHERE name = 'bob'` would rewrite to + * `... WHERE name = AND sql_deleted = 0`, which is not valid SQL. + */ +export function extractWhereClause(statement: string): string | null { + const stripped = stripNoise(statement); + const match = stripped.match(/WHERE\s(.+?)(?:ORDER\s+BY|LIMIT|RETURNING|$)/is); + if (!match || match.index === undefined || !match[1]) return null; + const start = match.index + 'WHERE'.length + 1; + return statement.slice(start, start + match[1].length).trim(); +} + +/** + * The trailing `RETURNING ...`, if any. sqlite-wasm can step a RETURNING statement directly, so + * the clause is carried across the rewrite rather than dropped: a soft delete that was asked for + * its rows should still hand them back, and appending the guard after it would not even parse. + */ +export function extractReturningClause(statement: string): string | null { + const stripped = stripNoise(statement); + const match = stripped.match(/\bRETURNING\b/i); + if (!match || match.index === undefined) return null; + const clause = statement.slice(match.index).trim(); + return clause.endsWith(';') ? clause.slice(0, -1).trim() : clause; +} + +/** + * Rewrite a DELETE into a soft delete. Returns the statement unchanged when the database does + * not participate in sync, so this is safe to run over every DELETE. + */ +export function softDeleteRewrite(statement: string, syncEnabled: boolean): string { + if (!syncEnabled) return statement; + if (statementKind(statement) !== 'DELETE') return statement; + + const tableName = extractTableName(statement); + if (!tableName) throw new Error('deleteSQL: cannot find a table name'); + const whereClause = extractWhereClause(statement); + if (!whereClause) throw new Error('deleteSQL: cannot find a WHERE clause'); + + const where = whereClause.endsWith(';') ? whereClause.slice(0, -1) : whereClause; + const returning = extractReturningClause(statement); + const suffix = returning ? ` ${returning}` : ''; + // The caller's clause is parenthesised because AND binds tighter than OR: without the brackets, + // `WHERE a = 1 OR b = 2` becomes `a = 1 OR (b = 2 AND sql_deleted = 0)`, so the guard covers + // only the last disjunct and an already-deleted row is marked again on every repeat. + return `UPDATE ${tableName} SET sql_deleted = 1 WHERE (${where}) AND sql_deleted = 0${suffix};`; +} diff --git a/src/web/worker/sync.ts b/src/web/worker/sync.ts new file mode 100644 index 00000000..b291dd7d --- /dev/null +++ b/src/web/worker/sync.ts @@ -0,0 +1,98 @@ +/** + * Sync-table support, ported from `electron/src/electron-utils/Database.ts` and + * `ImportExportJson/exportToJson.ts` (MIT, this repo), cross-checked against jeep-sqlite (MIT), + * whose implementation is the same logic under the same names. + * + * The convention (documented in `docs/info_releases.md:36-56`): a database opts into sync by + * giving its tables both a `last_modified` and a `sql_deleted` column. Once `sync_table` exists, + * a DELETE becomes a soft delete (`statements.ts`), and `deleteExportedRows` is what finally + * removes rows that a completed export has already carried away. + * + * Two rows in `sync_table` matter: id 1 is the synchronisation date set by `setSyncDate`, id 2 + * is the last export date written by `exportToJson`. + */ +import type { Connection } from './engine'; +import { quoteIdent } from './statements'; + +export const SYNC_TABLE = 'sync_table'; + +/** Does any user table carry the named column? That is what marks sync participation. */ +function anyTableHasColumn(conn: Connection, column: string): boolean { + for (const table of conn.tableList()) { + if (table === SYNC_TABLE) continue; + const rows = conn.query(`PRAGMA table_info(${quoteIdent(table)})`); + if (rows.some((row: any) => row.name === column)) return true; + } + return false; +} + +export function hasLastModified(conn: Connection): boolean { + return anyTableHasColumn(conn, 'last_modified'); +} + +export function hasSqlDeleted(conn: Connection): boolean { + return anyTableHasColumn(conn, 'sql_deleted'); +} + +/** True when this database participates in the soft-delete protocol. */ +export function isSyncEnabled(conn: Connection): boolean { + return hasLastModified(conn) && hasSqlDeleted(conn); +} + +export function createSyncTable(conn: Connection): number { + if (conn.tableExists(SYNC_TABLE)) return 0; + if (!isSyncEnabled(conn)) { + throw new Error('CreateSyncTable: No last_modified/sql_deleted columns in tables'); + } + const seconds = Math.round(new Date().getTime() / 1000); + const statements = + `CREATE TABLE IF NOT EXISTS ${SYNC_TABLE} (id INTEGER PRIMARY KEY NOT NULL, sync_date INTEGER);` + + `INSERT INTO ${SYNC_TABLE} (sync_date) VALUES (${seconds});`; + return conn.executeBatch(statements).changes; +} + +export function setSyncDate(conn: Connection, syncDate: string): void { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('SetSyncDate: No sync_table available'); + const seconds = Math.round(new Date(syncDate).getTime() / 1000); + if (Number.isNaN(seconds)) throw new Error(`SetSyncDate: ${syncDate} is not a valid date`); + conn.executeBatch(`UPDATE ${SYNC_TABLE} SET sync_date = ${seconds} WHERE id = 1;`); +} + +export function getSyncDate(conn: Connection): number { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('GetSyncDate: No sync_table available'); + const rows = conn.query(`SELECT sync_date FROM ${SYNC_TABLE} WHERE id = ?`, [1]); + if (rows.length === 0) throw new Error('GetSyncDate: no syncDate available'); + const value = Number(rows[0][Object.keys(rows[0])[0]]); + if (!(value > 0)) throw new Error('GetSyncDate: no syncDate available'); + return value; +} + +/** + * Physically remove the rows a previous export already carried away: soft-deleted, and older + * than the last export date written to `sync_table` id 2 by `exportToJson`. + */ +export function deleteExportedRows(conn: Connection): void { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('DeleteExportedRows: No sync_table available'); + const rows = conn.query(`SELECT sync_date FROM ${SYNC_TABLE} WHERE id = ?`, [2]); + const lastExportDate = rows.length > 0 ? Number(rows[0][Object.keys(rows[0])[0]]) : -1; + if (!(lastExportDate > 0)) { + throw new Error('DeleteExportedRows: no last exported date available'); + } + const tables = conn.tableList().filter((name: string) => name !== SYNC_TABLE); + if (tables.length === 0) throw new Error("DeleteExportedRows: No table's names returned"); + + conn.withOptionalTransaction(true, () => { + for (const table of tables) { + const columns = conn.query(`PRAGMA table_info(${quoteIdent(table)})`).map((row: any) => row.name); + if (!columns.includes('sql_deleted') || !columns.includes('last_modified')) continue; + // Bypass the rewrite: reclaiming soft-deleted rows is the one place a real DELETE is meant. + conn.run( + `DELETE FROM ${quoteIdent(table)} WHERE sql_deleted = 1 AND last_modified < ?`, + [lastExportDate], + false, + false, + ); + } + return 0; + }); +} diff --git a/src/web/worker/tiers.ts b/src/web/worker/tiers.ts new file mode 100644 index 00000000..5878258c --- /dev/null +++ b/src/web/worker/tiers.ts @@ -0,0 +1,115 @@ +/** + * Tier selection: single-owner lock first, then the VFS install, then a CLASSIFIED fallback. + * + * The classification is the whole point. A second browsing context that installs the pool while + * the first still holds the sync access handles gets a `NoModificationAllowedError`, which looks + * exactly like "this platform has no OPFS" unless you inspect it. Treating it as a fallback + * would hand the app an empty `:memory:` database on top of its real data, and the next flush + * would write that empty image over the stored one. So a busy pool fails loudly and only genuine + * capability gaps reach tier 2. + */ +import { MULTI_TAB_LOCKED, SQLiteWebError, messageOf } from '../errors'; +import type { Tier, WorkerInitArgs } from '../protocol'; +import { ownerLockName } from '../protocol'; + +export interface TierSelection { + tier: Tier; + poolUtil: any | null; + fallbackReason?: string; +} + +/** + * Take the owner lock and never release it: the held callback returns a promise that never + * settles, so the lock lives exactly as long as this worker. Resolves false when another + * context already owns it. + */ +export function acquireOwnerLock(poolName: string): Promise { + const locks = (navigator as any).locks; + if (!locks || typeof locks.request !== 'function') { + // No Web Locks means no way to gate. Proceed; the VFS install is still the backstop, + // and its busy-pool rejection is classified below rather than silently downgraded. + return Promise.resolve(true); + } + return new Promise((resolve, reject) => { + locks + .request(ownerLockName(poolName), { ifAvailable: true }, (lock: unknown) => { + if (!lock) { + resolve(false); + return undefined; + } + resolve(true); + return new Promise(() => { + /* held until this worker is terminated */ + }); + }) + .catch(reject); + }); +} + +/** A rejection that means "someone else owns the pool", not "this platform cannot do OPFS". */ +function isPoolBusy(err: unknown): boolean { + const name = (err as any)?.name; + if (name === 'NoModificationAllowedError' || name === 'InvalidStateError') return true; + return /access handles cannot be created|no modification allowed/i.test(messageOf(err)); +} + +/** The only three rejections that legitimately mean tier 2. */ +function isCapabilityGap(err: unknown): boolean { + const message = messageOf(err); + if (/missing required opfs apis/i.test(message)) return true; + if (/too old for opfs-sahpool/i.test(message)) return true; + return (err as any)?.name === 'SecurityError'; +} + +function opfsApisPresent(): boolean { + const g = globalThis as any; + return !!( + g.FileSystemHandle && + g.FileSystemDirectoryHandle && + typeof g.FileSystemFileHandle?.prototype?.createSyncAccessHandle === 'function' && + navigator?.storage?.getDirectory + ); +} + +export async function selectTier(sqlite3: any, args: WorkerInitArgs): Promise { + if (args.forceTier2) { + return { tier: 2, poolUtil: null, fallbackReason: 'forced by configuration' }; + } + + const owned = await acquireOwnerLock(args.poolName); + if (!owned) throw new SQLiteWebError(MULTI_TAB_LOCKED, { name: 'SQLiteWebLockedError', code: 'LOCKED' }); + + if (typeof sqlite3.installOpfsSAHPoolVfs !== 'function') { + return { tier: 2, poolUtil: null, fallbackReason: 'installOpfsSAHPoolVfs is unavailable in this build' }; + } + if (!opfsApisPresent()) { + return { tier: 2, poolUtil: null, fallbackReason: 'Missing required OPFS APIs.' }; + } + + try { + if (args.simulateInstallError) { + // Test hook: exercise the classifier without needing a second browsing context. The value + // is used as both the DOMException name and the message, so either arm can be driven. + const fake: any = new Error(args.simulateInstallError); + fake.name = args.simulateInstallError; + throw fake; + } + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + directory: args.directory, + name: args.poolName, + }); + return { tier: 1, poolUtil }; + } catch (err) { + if (isPoolBusy(err)) { + throw new SQLiteWebError(MULTI_TAB_LOCKED, { name: 'SQLiteWebLockedError', code: 'LOCKED', cause: err }); + } + if (isCapabilityGap(err)) { + return { tier: 2, poolUtil: null, fallbackReason: messageOf(err) }; + } + // Unrecognised: do not guess. Silently degrading here is what loses user data. + throw new SQLiteWebError(`Could not initialise the opfs-sahpool VFS: ${messageOf(err)}`, { + name: (err as any)?.name, + cause: err, + }); + } +} diff --git a/src/web/worker/upgrades.ts b/src/web/worker/upgrades.ts new file mode 100644 index 00000000..f4e7e9eb --- /dev/null +++ b/src/web/worker/upgrades.ts @@ -0,0 +1,77 @@ +/** + * Version upgrade execution, ported from `electron/src/electron-utils/utilsUpgrade.ts` + * (MIT, this repo) with the same contract: run every registered upgrade whose `toVersion` sits + * above the database's current `user_version` and at or below the requested version, in + * ascending order, each inside its own transaction with foreign keys disabled. + * + * The electron version takes a file-level backup around the whole ladder and restores it on + * failure. There is no file copy on tier 1 (the pool owns the file) and no meaningful one on + * tier 2 either, so the web port takes an in-memory image of the database instead and restores + * from that, which gives the same all-or-nothing guarantee without touching the VFS. + */ +import { prefixed } from '../errors'; +import type { SerializedUpgrade } from '../protocol'; + +import type { Connection } from './engine'; + +export interface UpgradeOutcome { + upgraded: boolean; + fromVersion: number; + toVersion: number; + changes: number; +} + +export function sortUpgrades(upgrades: SerializedUpgrade[]): SerializedUpgrade[] { + return [...upgrades].sort((a, b) => a.toVersion - b.toVersion); +} + +/** + * @param restore called with a pre-upgrade image when the ladder fails partway, so the caller + * can put the database back exactly as it was. Omitted for read-only opens. + */ +export function runUpgrades( + conn: Connection, + upgrades: SerializedUpgrade[], + targetVersion: number, + restore?: (image: Uint8Array) => void, +): UpgradeOutcome { + const fromVersion = conn.userVersion(); + const pending = sortUpgrades(upgrades).filter((u) => u.toVersion > fromVersion && u.toVersion <= targetVersion); + if (pending.length === 0) { + return { upgraded: false, fromVersion, toVersion: fromVersion, changes: 0 }; + } + + for (const upgrade of pending) { + if (!upgrade.statements || upgrade.statements.length === 0) { + throw new Error(`onUpgrade: statements not given for version ${upgrade.toVersion}`); + } + } + + const backup = restore ? conn.serialize() : undefined; + let changes = 0; + let reached = fromVersion; + + try { + for (const upgrade of pending) { + conn.setForeignKeyConstraintsEnabled(false); + try { + changes += conn.withOptionalTransaction(true, () => { + let stepChanges = 0; + for (const statement of upgrade.statements) { + stepChanges += conn.executeBatch(statement).changes; + } + return stepChanges; + }); + conn.setUserVersion(upgrade.toVersion); + reached = upgrade.toVersion; + } finally { + conn.setForeignKeyConstraintsEnabled(true); + } + } + } catch (err) { + if (backup && restore) restore(backup); + throw prefixed('onUpgrade', err); + } + + return { upgraded: true, fromVersion, toVersion: reached, changes }; +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts new file mode 100644 index 00000000..ffea96d0 --- /dev/null +++ b/src/web/worker/worker.ts @@ -0,0 +1,625 @@ +/** + * Worker entry: owns the wasm heap, the VFS, and every open database. The main thread never + * touches any of them. + * + * Ops are dispatched from a single table and each one returns a plain, structured-cloneable + * value. Anything that needs `changes()` or `last_insert_rowid()` reads them inside the same op + * as the statement that produced them, so concurrent calls cannot interleave between the write + * and the read. + */ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +import { toErrorPayload } from '../errors'; +import type { ExecResult, OpenArgs, WorkerInitArgs, WorkerInitResult, WorkerRequest } from '../protocol'; +import { BOOT_ID, EVENT_ID, EV_EXPORT_PROGRESS, EV_IMPORT_PROGRESS } from '../protocol'; + +import type { AdoptionTarget } from './adoption'; +import type { AssetTarget } from './assets'; +import { copyFromAssets, getFromHTTPRequest } from './assets'; +import { Connection, wantsRows } from './engine'; +import { ImageStore, deserializeInto } from './images'; +import { exportJson } from './json/export'; +import { importJson } from './json/import'; +import { isJsonSQLite, parseJsonSQLite } from './json/validate'; +import { migrateFromJeep } from './migrate-jeep'; +import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; +import { promoteImages } from './promote'; +import * as sync from './sync'; +import { selectTier } from './tiers'; +import { runUpgrades } from './upgrades'; + +/** + * The published types declare `init()` with no parameters, but the runtime export is an + * Emscripten module factory and does accept a Module config: that is how `print`, `printErr` + * and `locateFile` are wired. Narrow cast rather than a broad `any` on the import. + */ +interface Sqlite3InitConfig { + print?: (...args: any[]) => void; + printErr?: (...args: any[]) => void; + locateFile?: (file: string) => string; +} +const initModule = sqlite3InitModule as unknown as (config?: Sqlite3InitConfig) => Promise; + +let sqlite3: any = null; +let poolUtil: any = null; +let tier: 1 | 2 = 2; +/** Constructed at init, because its IndexedDB name is derived from the configured pool name. */ +let images = new ImageStore('capacitor-sqlite'); +const connections = new Map(); + +function requireInit(): void { + if (!sqlite3) throw new Error('The SQLite worker is not initialised.'); +} + +/** Raise a plugin event. The facade forwards these to notifyListeners. */ +function raise(event: string, data: any): void { + self.postMessage({ id: EVENT_ID, event, data }); +} + +/** + * Where copyFromAssets and getFromHTTPRequest put what they fetch, abstracted so the same code + * serves the pool on tier 1 and the image store on tier 2. + */ +function assetTarget(): AssetTarget { + return { + poolUtil: tier === 1 ? poolUtil : null, + exists: async (storage: string) => { + if (tier === 1) return (poolUtil.getFileNames() as string[]).includes(poolPath(storage)); + return images.has(storage); + }, + adopt: async (storage: string, bytes: Uint8Array) => { + if (tier === 1) { + await reserveCapacity(poolUtil, 2); + poolUtil.importDb(poolPath(storage), bytes); + } else { + await images.put(storage, bytes); + } + }, + }; +} + +/** + * Adoption into whichever tier is active, shared by the jeep-sqlite migration and the tier-2 to + * tier-1 promotion. Adoption goes through the same import path a downloaded database takes; + * verification opens what was adopted and asks sqlite whether it is a database at all, which is + * the only check that would catch a truncated image. + */ +function adoptionTarget(): AdoptionTarget { + const target = assetTarget(); + return { + exists: (storage) => target.exists(storage), + adopt: (storage, bytes) => target.adopt(storage, bytes), + verify: async (storage) => { + // The presence check comes first and is not optional. Opening a tier 2 database whose image + // is missing yields an empty `:memory:` database, and `integrity_check` says `ok` to that, + // so without this a write that never reached IndexedDB would be reported as migrated and + // the legacy copy deleted. + if (!(await target.exists(storage))) throw new Error('nothing was stored under that name'); + const conn = tier === 1 ? openRaw(storage, false) : await openTier2(storage, false); + try { + const [row] = conn.query('PRAGMA integrity_check'); + const verdict = row?.integrity_check; + if (verdict !== 'ok') throw new Error(`integrity_check returned ${verdict ?? 'nothing'}`); + } finally { + if (conn.isOpen) conn.close(); + } + }, + discard: async (storage) => { + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + }, + }; +} + +/** + * importFromJson names its own target database, which may or may not already be open. Reuse an + * open read-write connection when there is one so the caller keeps seeing its own data, and + * otherwise open a scratch connection and close it again. + */ +async function withWritableConnection(database: string, body: (conn: Connection) => Promise | T): Promise { + const existing = connections.get(connKey(database, false)); + if (existing) return body(existing); + + const storage = storageName(database); + const conn = tier === 1 ? openRaw(storage, false) : await openTier2(storage, false); + try { + const result = await body(conn); + if (tier !== 1) await images.put(storage, conn.serialize()); + return result; + } finally { + if (conn.isOpen) conn.close(); + } +} + +function connection(database: string, readonly: boolean): Connection { + const conn = connections.get(connKey(database, readonly)); + if (!conn) throw new Error(`Database ${database} is not open`); + return conn; +} + +/** Every connection open on a database, whichever mode it was opened in. */ +function connectionsFor(database: string): Connection[] { + return [connections.get(connKey(database, false)), connections.get(connKey(database, true))].filter( + (c): c is Connection => !!c, + ); +} + +function openRaw(storage: string, readonly: boolean): Connection { + const conn = + tier === 1 + ? new Connection(storage, readonly, new poolUtil.OpfsSAHPoolDb(storage, readonly ? 'r' : 'cw'), sqlite3) + : // Tier 2 always opens writable: sqlite3_deserialize needs it. Read-only is applied after + // the image is loaded, with PRAGMA query_only. + new Connection(storage, readonly, new sqlite3.oo1.DB(':memory:', 'c'), sqlite3); + // sqlite defaults foreign keys OFF, per connection. Every other platform of this plugin turns + // them on at open (electron `utilsSQLite.ts:63`, jeep `Database.open`), and the soft-delete + // cascade relies on the schema's declared actions being real, so web does the same. The upgrade + // and JSON-import paths that need them off already toggle them explicitly. + conn.setForeignKeyConstraintsEnabled(true); + return conn; +} + +async function openTier2(storage: string, readonly: boolean): Promise { + const image = await images.get(storage); + if (!image && readonly) throw new Error(`Database ${storage} does not exist`); + const conn = openRaw(storage, readonly); + if (image) deserializeInto(sqlite3, conn.raw, image); + if (readonly) conn.setQueryOnly(true); + return conn; +} + +async function flushIfTier2(conn: Connection): Promise { + if (tier !== 1 && !conn.isReadonly && conn.isOpen) { + await images.put(conn.storage, conn.serialize()); + } +} + +async function storedNames(): Promise { + if (tier === 1) return (poolUtil.getFileNames() as string[]).map(fromPoolPath); + const names = new Set(await images.keys()); + for (const conn of connections.values()) names.add(conn.storage); + return [...names].sort(); +} + +const ops: Record any> = { + async init(args: WorkerInitArgs): Promise { + if (!sqlite3) { + sqlite3 = await initModule({ + print: () => undefined, + printErr: () => undefined, + // Without an override the wasm is resolved next to this worker file, which is how the + // shipped dist/web-worker.js + dist/sqlite3.wasm pair works. + ...(args.wasmUrl ? { locateFile: () => args.wasmUrl as string } : {}), + }); + } + images = new ImageStore(args.poolName); + const selection = await selectTier(sqlite3, args); + tier = selection.tier; + poolUtil = selection.poolUtil; + if (tier === 1) await reserveCapacity(poolUtil, 4); + + // Both passes run before any connection is opened, so neither can race a live database. + // Promotion goes first: an image in the fallback store is this app's own data from an earlier + // boot, so it outranks anything the jeep-sqlite store may name the same. + const promotion = tier === 1 ? await promoteImages(images, adoptionTarget()) : null; + const migration = args.skipJeepMigration ? null : await migrateFromJeep(images, adoptionTarget()); + + return { + tier, + sqliteVersion: sqlite3.version.libVersion, + ...(selection.fallbackReason ? { fallbackReason: selection.fallbackReason } : {}), + ...(migration && (migration.ran || migration.warning) ? { migration } : {}), + ...(promotion && (promotion.promoted.length > 0 || promotion.warning) ? { promotion } : {}), + }; + }, + + async open({ database, readonly, version, upgrades }: OpenArgs) { + requireInit(); + const key = connKey(database, readonly); + if (connections.has(key)) return { alreadyOpen: true }; + const storage = storageName(database); + + let conn = tier === 1 ? openRaw(storage, readonly) : await openTier2(storage, readonly); + + let outcome = { upgraded: false, fromVersion: conn.userVersion(), toVersion: conn.userVersion(), changes: 0 }; + if (!readonly && upgrades && upgrades.length > 0) { + try { + outcome = runUpgrades(conn, upgrades, version, (image) => { + conn.close(); + if (tier === 1) { + poolUtil.importDb(poolPath(storage), image); + conn = openRaw(storage, false); + } else { + conn = openRaw(storage, false); + deserializeInto(sqlite3, conn.raw, image); + } + }); + } catch (err) { + connections.set(key, conn); + await flushIfTier2(conn); + throw err; + } + } else if (!readonly && version > conn.userVersion()) { + conn.setUserVersion(version); + outcome = { upgraded: false, fromVersion: outcome.fromVersion, toVersion: version, changes: 0 }; + } + + connections.set(key, conn); + if (outcome.upgraded) await flushIfTier2(conn); + return outcome; + }, + + async close({ database, readonly }: { database: string; readonly: boolean }) { + requireInit(); + const key = connKey(database, readonly); + const conn = connections.get(key); + if (!conn) return { closed: false }; + await flushIfTier2(conn); + conn.close(); + connections.delete(key); + return { closed: true }; + }, + + async execute({ + database, + readonly, + statements, + transaction, + }: { + database: string; + readonly: boolean; + statements: string; + transaction: boolean; + }): Promise { + const conn = connection(database, readonly); + return conn.withOptionalTransaction(transaction, () => conn.executeBatch(statements)); + }, + + async run({ + database, + readonly, + statement, + values, + transaction, + returnMode, + }: { + database: string; + readonly: boolean; + statement: string; + values?: any[]; + transaction: boolean; + returnMode?: string; + }): Promise { + const conn = connection(database, readonly); + const result = conn.withOptionalTransaction(transaction, () => + conn.run(statement, values, wantsRows(statement, returnMode)), + ); + if (returnMode === 'one' && result.values) result.values = result.values.slice(0, 1); + return result; + }, + + async executeSet({ + database, + readonly, + set, + transaction, + returnMode, + }: { + database: string; + readonly: boolean; + set: { statement?: string; values?: any[] }[]; + transaction: boolean; + returnMode?: string; + }): Promise { + const conn = connection(database, readonly); + return conn.withOptionalTransaction(transaction, () => { + const collected: any[] = []; + const out: ExecResult = { changes: 0, lastId: -1, values: collected }; + set.forEach((entry, index) => { + const statement = entry?.statement; + if (!statement) throw new Error(`ExecuteSet: no statement for index ${index}`); + const rows = entry.values ?? []; + // A set entry whose values are themselves arrays runs the statement once per row. + const batches: any[][] = Array.isArray(rows[0]) ? (rows as any[][]) : [rows]; + for (const batch of batches) { + const step = conn.run(statement, batch, wantsRows(statement, returnMode)); + out.changes += step.changes; + out.lastId = step.lastId; + // Flat array of row objects, matching the native implementations. + if (step.values?.length) collected.push(...step.values); + } + }); + if (returnMode === 'one') out.values = collected.slice(0, 1); + return out; + }); + }, + + async query({ + database, + readonly, + statement, + values, + }: { + database: string; + readonly: boolean; + statement: string; + values?: any[]; + }) { + return { values: connection(database, readonly).query(statement, values) }; + }, + + async beginTransaction({ database, readonly }: { database: string; readonly: boolean }) { + connection(database, readonly).beginTransaction(); + return { changes: 0, lastId: -1 }; + }, + async commitTransaction({ database, readonly }: { database: string; readonly: boolean }) { + const conn = connection(database, readonly); + conn.commitTransaction(); + await flushIfTier2(conn); + return { changes: 0, lastId: -1 }; + }, + async rollbackTransaction({ database, readonly }: { database: string; readonly: boolean }) { + connection(database, readonly).rollbackTransaction(); + return { changes: 0, lastId: -1 }; + }, + async isTransactionActive({ database, readonly }: { database: string; readonly: boolean }) { + return { result: connection(database, readonly).isTransactionActive }; + }, + + async getVersion({ database, readonly }: { database: string; readonly: boolean }) { + return { version: connection(database, readonly).userVersion() }; + }, + + async isDBOpen({ database, readonly }: { database: string; readonly: boolean }) { + const conn = connections.get(connKey(database, readonly)); + return { result: !!conn && conn.isOpen }; + }, + + async isDatabase({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + if (tier === 1) return { result: (poolUtil.getFileNames() as string[]).includes(poolPath(storage)) }; + // On tier 1 a database exists from the moment it is opened. Tier 2 only writes its image at + // a flush point, so an open connection counts as existing too, otherwise a freshly created + // database would report false until it was closed. + if (connectionsFor(database).length > 0) return { result: true }; + return { result: await images.has(storage) }; + }, + + async isTableExists({ database, readonly, table }: { database: string; readonly: boolean; table: string }) { + return { result: connection(database, readonly).tableExists(table) }; + }, + + async getTableList({ database, readonly }: { database: string; readonly: boolean }) { + return { values: connection(database, readonly).tableList() }; + }, + + async getDatabaseList() { + requireInit(); + return { values: await storedNames() }; + }, + + async deleteDatabase({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + for (const conn of connectionsFor(database)) { + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + return {}; + }, + + async saveToStore({ database }: { database: string }) { + requireInit(); + if (tier === 1) return { flushed: false }; // always durable + const conn = connections.get(connKey(database, false)); + if (!conn) throw new Error(`Database ${database} is not open`); + await flushIfTier2(conn); + return { flushed: true }; + }, + + async exportDb({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + const conn = connections.get(connKey(database, false)) ?? connections.get(connKey(database, true)); + if (conn) return { bytes: conn.serialize() }; + if (tier === 1) return { bytes: poolUtil.exportFile(poolPath(storage)) }; + const image = await images.get(storage); + if (!image) throw new Error(`Database ${database} does not exist`); + return { bytes: image }; + }, + + // ---------------------------------------------------------------- JSON pipeline + + async isJsonValid({ jsonstring }: { jsonstring: string }) { + try { + return { result: isJsonSQLite(JSON.parse(jsonstring)) }; + } catch { + return { result: false }; + } + }, + + async importFromJson({ jsonstring }: { jsonstring: string }) { + requireInit(); + const jsonData = parseJsonSQLite(jsonstring); + if (jsonData.encrypted) { + throw new Error('ImportFromJson: encrypted databases are not supported on the web platform'); + } + const mode = jsonData.mode ?? 'full'; + const database = jsonData.database; + const storage = storageName(database); + + // `overwrite` with a full import means start from an empty database, not merge into one. + // Whatever was open has to be closed first, since the file itself goes, but the caller is + // still holding those connections and gets "Database X is not open" on its next statement + // unless they are put back afterwards. + const reopen: boolean[] = []; + if (jsonData.overwrite && mode === 'full') { + for (const conn of connectionsFor(database)) { + reopen.push(conn.isReadonly); + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + } + + const progress = (message: string) => raise(EV_IMPORT_PROGRESS, { progress: message }); + progress(`Start importing the database ${database}`); + + const changes = await withWritableConnection(database, (conn) => { + // A full import into a database that already sits at the target version is a no-op, which + // is what makes repeated imports of the same payload cheap. + if (mode === 'full' && conn.tableList().length > 0) { + const current = conn.userVersion(); + if (jsonData.version < current) { + throw new Error(`ImportFromJson: Cannot import a version lower than ${current}`); + } + if (jsonData.version === current) return 0; + } + return importJson(conn, jsonData, progress); + }); + + for (const readonly of reopen) { + const conn = tier === 1 ? openRaw(storage, readonly) : await openTier2(storage, readonly); + connections.set(connKey(database, readonly), conn); + } + + progress(`Import completed, changes: ${changes}`); + return { changes, lastId: -1 }; + }, + + async exportToJson({ + database, + readonly, + jsonexportmode, + encrypted, + }: { + database: string; + readonly: boolean; + jsonexportmode: string; + encrypted?: boolean; + }) { + if (encrypted) { + throw new Error('ExportToJson: encrypted export is not supported on the web platform'); + } + const conn = connection(database, readonly); + const progress = (message: string) => raise(EV_EXPORT_PROGRESS, { progress: message }); + const exported = exportJson(conn, database, jsonexportmode, progress); + if (tier !== 1 && !conn.isReadonly) await images.put(conn.storage, conn.serialize()); + return { export: exported }; + }, + + // ---------------------------------------------------------------- sync tables + + async createSyncTable({ database }: { database: string }) { + const conn = connection(database, false); + const changes = sync.createSyncTable(conn); + conn.invalidateSyncCache(); + await flushIfTier2(conn); + return { changes, lastId: -1 }; + }, + + async setSyncDate({ database, syncdate }: { database: string; syncdate: string }) { + const conn = connection(database, false); + sync.setSyncDate(conn, syncdate); + await flushIfTier2(conn); + return {}; + }, + + async getSyncDate({ database, readonly }: { database: string; readonly: boolean }) { + return { syncDate: sync.getSyncDate(connection(database, readonly)) }; + }, + + async deleteExportedRows({ database }: { database: string }) { + const conn = connection(database, false); + sync.deleteExportedRows(conn); + await flushIfTier2(conn); + return {}; + }, + + // ---------------------------------------------------------------- assets and downloads + + async copyFromAssets({ base, overwrite }: { base: string; overwrite: boolean }) { + requireInit(); + return copyFromAssets(assetTarget(), base, overwrite); + }, + + async getFromHTTPRequest({ url, overwrite }: { url: string; overwrite: boolean }) { + requireInit(); + const storage = await getFromHTTPRequest(assetTarget(), url, overwrite); + return { storage }; + }, + + /** Adopt a whole image the main thread produced, used by getFromLocalDiskToStore. */ + async adoptImage({ database, bytes, overwrite }: { database: string; bytes: Uint8Array; overwrite: boolean }) { + requireInit(); + const storage = storageName(database); + const target = assetTarget(); + if (!overwrite && (await target.exists(storage))) return { adopted: false, storage }; + for (const conn of connectionsFor(database)) { + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + await target.adopt(storage, bytes); + return { adopted: true, storage }; + }, + + /** + * pauseVfs throws SQLITE_MISUSE while any database is open, so the close-first sequence is + * part of the op rather than something the caller has to remember. Wiring these to Capacitor + * appStateChange is M4; the ops exist now so the worker side is done. + */ + async pause() { + requireInit(); + if (tier !== 1) return { paused: false, reopen: [], interrupted: [] }; + const reopen: { database: string; readonly: boolean }[] = []; + // A transaction cannot survive the close, so it is rolled back and named. Reporting it is the + // honest option (PLAN 6.7): the caller that opened it will never hear about it otherwise. + const interrupted: string[] = []; + for (const [key, conn] of connections) { + const database = key.substring(3); + if (conn.isTransactionActive) { + interrupted.push(database); + try { + conn.rollbackTransaction(); + } catch { + // Closing the connection discards it either way. + } + } + await flushIfTier2(conn); + conn.close(); + reopen.push({ database, readonly: conn.isReadonly }); + } + connections.clear(); + poolUtil.pauseVfs(); + return { paused: poolUtil.isPaused(), reopen, interrupted }; + }, + + async unpause() { + requireInit(); + if (tier !== 1) return { paused: false }; + await poolUtil.unpauseVfs(); + return { paused: poolUtil.isPaused() }; + }, + + async isPaused() { + requireInit(); + return { paused: tier === 1 ? poolUtil.isPaused() : false }; + }, +}; + +self.onmessage = async (event: MessageEvent) => { + const { id, op, args } = (event.data ?? {}) as WorkerRequest; + try { + const fn = ops[op]; + if (!fn) throw new Error(`Unknown worker op: ${op}`); + self.postMessage({ id, ok: true, result: await fn(args ?? {}) }); + } catch (err) { + self.postMessage({ id, ok: false, error: toErrorPayload(err) }); + } +}; + +self.postMessage({ id: BOOT_ID, ok: true, result: { booted: true } }); diff --git a/test/web/assets.test.ts b/test/web/assets.test.ts new file mode 100644 index 00000000..337eac3c --- /dev/null +++ b/test/web/assets.test.ts @@ -0,0 +1,181 @@ +/** + * copyFromAssets and getFromHTTPRequest. + * + * Fixtures are real SQLite files, built in the browser through the plugin itself, uploaded to + * the dev server's `/__fixture/` endpoint (see vitest.config.mts), and then fetched back by the + * worker over real HTTP. Nothing leaves localhost, and the streaming path is exercised for real + * rather than through a stubbed fetch, which could not reach the worker's scope anyway. + */ +import { beforeEach, describe, expect, test } from 'vitest'; + +import { setSqliteWebOptions } from '../../src/web/worker-factory'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const BASE = '/__fixture/dbs/'; + +async function putFixture(path: string, body: Uint8Array | string): Promise { + const res = await fetch(path, { method: 'PUT', body: typeof body === 'string' ? body : (body.slice() as any) }); + if (!res.ok) throw new Error(`fixture upload failed for ${path}: ${res.status}`); +} + +beforeEach(async () => { + await fetch('/__fixture/reset', { method: 'DELETE' }); +}); + +/** Build a real SQLite file through the plugin and return its bytes. */ +async function makeDatabaseBytes(sqlite: any, plugin: any, name: string, rows: string[]): Promise { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + for (const row of rows) await db.run('INSERT INTO t (v) VALUES (?)', [row]); + await sqlite.closeConnection(name, false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: name }); + return bytes; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function harness() { + const h = await startHarness(tier); + // Point copyFromAssets at the fixture directory instead of assets/databases/. + setSqliteWebOptions({ assetsPath: BASE }); + return h; + } + + test(`${label}: copyFromAssets pulls every database in databases.json`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}alpha.db`, await makeDatabaseBytes(sqlite, plugin, 'srcAlpha', ['a1', 'a2'])); + await putFixture(`${BASE}beta.db`, await makeDatabaseBytes(sqlite, plugin, 'srcBeta', ['b1'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['alpha.db', 'beta.db'])); + + await sqlite.copyFromAssets(true); + + const list = await sqlite.getDatabaseList(); + expect(list.values).toContain('alphaSQLite.db'); + expect(list.values).toContain('betaSQLite.db'); + + const db = await sqlite.createConnection('alpha', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t ORDER BY id')).values?.map((r: any) => r.v)).toEqual(['a1', 'a2']); + await sqlite.closeConnection('alpha', false); + }); + + test(`${label}: copyFromAssets honours overwrite = false`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}keep.db`, await makeDatabaseBytes(sqlite, plugin, 'srcOne', ['original'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['keep.db'])); + await sqlite.copyFromAssets(true); + + await putFixture(`${BASE}keep.db`, await makeDatabaseBytes(sqlite, plugin, 'srcTwo', ['replacement'])); + await sqlite.copyFromAssets(false); + + const db = await sqlite.createConnection('keep', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('original'); + await sqlite.closeConnection('keep', false); + }); + + test(`${label}: overwrite = true really replaces the stored database`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}swap.db`, await makeDatabaseBytes(sqlite, plugin, 'srcA', ['first'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['swap.db'])); + await sqlite.copyFromAssets(true); + + await putFixture(`${BASE}swap.db`, await makeDatabaseBytes(sqlite, plugin, 'srcB', ['second'])); + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('swap', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('second'); + await sqlite.closeConnection('swap', false); + }); + + test(`${label}: copyFromAssets accepts the object form of the manifest`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}obj.db`, await makeDatabaseBytes(sqlite, plugin, 'srcObj', ['objform'])); + await putFixture(`${BASE}databases.json`, JSON.stringify({ databaseList: ['obj.db'] })); + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('obj', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('objform'); + await sqlite.closeConnection('obj', false); + }); + + test(`${label}: a missing manifest fails loudly`, async () => { + const { sqlite } = await harness(); + await expect(sqlite.copyFromAssets(true)).rejects.toThrow(/CopyFromAssets/i); + }); + + test(`${label}: getFromHTTPRequest streams a database in and raises its ended event`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture('/__fixture/remote.db', await makeDatabaseBytes(sqlite, plugin, 'srcHttp', ['downloaded'])); + const events: any[] = []; + await plugin.addListener('sqliteHTTPRequestEndedEvent', (e: any) => events.push(e)); + + await sqlite.getFromHTTPRequest(new URL('/__fixture/remote.db', location.href).href, true); + + expect(events.length).toBe(1); + expect(events[0].message).toBe('ended'); + + const db = await sqlite.createConnection('remote', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('downloaded'); + await sqlite.closeConnection('remote', false); + }); + + test(`${label}: a failed download raises the ended event with the error and rejects`, async () => { + const { sqlite, plugin } = await harness(); + const events: any[] = []; + await plugin.addListener('sqliteHTTPRequestEndedEvent', (e: any) => events.push(e)); + await expect( + sqlite.getFromHTTPRequest(new URL('/__fixture/never-uploaded.db', location.href).href, true), + ).rejects.toThrow(/GetFromHTTPRequest/i); + expect(events.length).toBe(1); + expect(events[0].message).toMatch(/^Error:/); + }); + + test(`${label}: a zip asset is unpacked into its databases`, async () => { + const { sqlite, plugin } = await harness(); + const bytes = await makeDatabaseBytes(sqlite, plugin, 'srcZip', ['zipped']); + const { zipSync } = await import('fflate'); + await putFixture(`${BASE}bundle.zip`, zipSync({ 'inner.db': bytes }, { level: 0 })); + await putFixture(`${BASE}databases.json`, JSON.stringify(['bundle.zip'])); + + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('inner', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('zipped'); + await sqlite.closeConnection('inner', false); + }); + + test(`${label}: a large download costs a chunk, not the file`, async () => { + const { sqlite, plugin } = await harness(); + // Big enough that a whole-buffer implementation would be obvious in the wasm heap. + const db = await sqlite.createConnection('srcBig', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE big (id INTEGER PRIMARY KEY NOT NULL, blob BLOB);'); + await db.execute( + `WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 128) + INSERT INTO big (blob) SELECT randomblob(65536) FROM c;`, + ); + await sqlite.closeConnection('srcBig', false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: 'srcBig' }); + expect(bytes.byteLength).toBeGreaterThan(8 * 1024 * 1024); + await putFixture('/__fixture/big.db', bytes); + + await sqlite.getFromHTTPRequest(new URL('/__fixture/big.db', location.href).href, true); + + const copy = await sqlite.createConnection('big', false, 'no-encryption', 1, false); + await copy.open(); + const count = await copy.query('SELECT count(*) AS n, sum(length(blob)) AS total FROM big'); + expect(count.values?.[0].n).toBe(128); + expect(Number(count.values?.[0].total)).toBe(128 * 65536); + const check = await copy.query('PRAGMA integrity_check'); + expect(check.values?.[0].integrity_check).toBe('ok'); + await sqlite.closeConnection('big', false); + }); +}); diff --git a/test/web/cascade.test.ts b/test/web/cascade.test.ts new file mode 100644 index 00000000..a0970571 --- /dev/null +++ b/test/web/cascade.test.ts @@ -0,0 +1,571 @@ +/** + * Foreign-key propagation for the soft delete (PLAN 13.4). + * + * The gap this closes is silent: without propagation a deleted parent is marked and its children + * are not, so the next export tells the server the parent is gone while still reporting the + * children live. The last test in the CASCADE block is the one that proves that divergence dead; + * the rest establish that each `ON DELETE` action means what the schema says it means. + */ +import { describe, expect, test } from 'vitest'; + +import { resolveTableName } from '../../src/web/worker/cascade'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe('table names as the caller wrote them', () => { + test.each([ + ['items', 'items'], + ['main.items', 'items'], + ['"my table"', 'my table'], + ['[items]', 'items'], + ['`items`', 'items'], + ])('%s resolves to %s', (raw, expected) => { + expect(resolveTableName(raw)).toBe(expected); + }); + + test('an attached database is refused rather than silently skipped', () => { + expect(() => resolveTableName('other.items')).toThrow(/attached database/i); + }); +}); + +/** Columns every sync-tracked table needs, plus the trigger convention the docs describe. */ +const SYNC_COLUMNS = `last_modified INTEGER DEFAULT (strftime('%s','now')), sql_deleted BOOLEAN DEFAULT 0`; + +const TREE_SCHEMA = ` + CREATE TABLE parents ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + ${SYNC_COLUMNS} + ); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + name TEXT NOT NULL, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE grandchildren ( + id INTEGER PRIMARY KEY NOT NULL, + child_id INTEGER, + name TEXT NOT NULL, + ${SYNC_COLUMNS}, + FOREIGN KEY (child_id) REFERENCES children(id) ON DELETE CASCADE + );`; + +const TREE_ROWS = ` + INSERT INTO parents (id, name) VALUES (1, 'keep'), (2, 'drop'); + INSERT INTO children (id, parent_id, name) VALUES (10, 1, 'keep-a'), (20, 2, 'drop-a'), (21, 2, 'drop-b'); + INSERT INTO grandchildren (id, child_id, name) VALUES (100, 10, 'keep-x'), (200, 20, 'drop-x'), (210, 21, 'drop-y');`; + +async function open(sqlite: any, name: string, schema: string, rows?: string) { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute(schema); + if (rows) await db.execute(rows); + return db; +} + +/** id -> sql_deleted, so an assertion can talk about the whole table at once. */ +async function marks(db: any, table: string): Promise> { + const rows = await db.query(`SELECT id, sql_deleted FROM ${table} ORDER BY id`); + const out: Record = {}; + for (const row of rows.values ?? []) out[Number(row.id)] = Number(row.sql_deleted); + return out; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: CASCADE marks children and grandchildren, and spares the other branch`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'casc', TREE_SCHEMA, TREE_ROWS); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + expect(await marks(db, 'grandchildren')).toEqual({ 100: 0, 200: 1, 210: 1 }); + + // Nothing was physically removed: a soft delete is a marking, at every level. + const counts = await db.query( + 'SELECT (SELECT count(*) FROM parents) AS p, (SELECT count(*) FROM children) AS c,' + + ' (SELECT count(*) FROM grandchildren) AS g', + ); + expect(counts.values?.[0]).toEqual({ p: 2, c: 3, g: 3 }); + await sqlite.closeConnection('casc', false); + }); + + test(`${label}: exportToJson reports a cascaded delete consistently at every level`, async () => { + // PLAN 13.4's scenario. Before the cascade existed, this export said the parent was deleted + // and its children were live, and the server had no way to notice. + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'cascexp', TREE_SCHEMA, TREE_ROWS); + await db.createSyncTable(); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + const exported = await db.exportToJson('full'); + const tables = Object.fromEntries((exported.export?.tables ?? []).map((t: any) => [t.name, t.values ?? []])); + + const deletedOf = (table: string, id: number) => { + const row = tables[table].find((values: any[]) => Number(values[0]) === id); + const schema: any[] = (exported.export?.tables ?? []).find((t: any) => t.name === table).schema ?? []; + const index = schema.findIndex((column: any) => column.column === 'sql_deleted'); + return Number(row[index]); + }; + + // The parent and its whole subtree agree: all gone. + expect(deletedOf('parents', 2)).toBe(1); + expect(deletedOf('children', 20)).toBe(1); + expect(deletedOf('children', 21)).toBe(1); + expect(deletedOf('grandchildren', 200)).toBe(1); + expect(deletedOf('grandchildren', 210)).toBe(1); + // The untouched branch is still live, so the cascade did not over-reach either. + expect(deletedOf('parents', 1)).toBe(0); + expect(deletedOf('children', 10)).toBe(0); + expect(deletedOf('grandchildren', 100)).toBe(0); + await sqlite.closeConnection('cascexp', false); + }); + + test(`${label}: SET NULL clears the reference and leaves the child live`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'setnull', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE SET NULL + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (10, 1), (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 0 }); + const rows = await db.query('SELECT id, parent_id FROM children ORDER BY id'); + expect(rows.values).toEqual([ + { id: 10, parent_id: 1 }, + { id: 20, parent_id: null }, + ]); + await sqlite.closeConnection('setnull', false); + }); + + test(`${label}: SET DEFAULT restores the column's declared default`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'setdef', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER DEFAULT 1, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE SET DEFAULT + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + const rows = await db.query('SELECT id, parent_id, sql_deleted FROM children'); + expect(rows.values).toEqual([{ id: 20, parent_id: 1, sql_deleted: 0 }]); + await sqlite.closeConnection('setdef', false); + }); + + test(`${label}: RESTRICT refuses the delete while live children exist`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'restrict', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE RESTRICT + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await expect(db.run('DELETE FROM parents WHERE id = ?', [2])).rejects.toThrow(/related items exist/i); + // Refused means refused: the parent is untouched, not half-deleted. + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 0 }); + expect(await marks(db, 'children')).toEqual({ 20: 0 }); + + // A parent with no children is unaffected by the constraint. + await db.run('DELETE FROM parents WHERE id = ?', [1]); + expect(await marks(db, 'parents')).toEqual({ 1: 1, 2: 0 }); + + // And once the child is gone, the parent can go too. + await db.run('DELETE FROM children WHERE id = ?', [20]); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'parents')).toEqual({ 1: 1, 2: 1 }); + await sqlite.closeConnection('restrict', false); + }); + + test(`${label}: NO ACTION leaves the child alone, which is what it asks for`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'noaction', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE NO ACTION + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'parents')).toEqual({ 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 20: 0 }); + await sqlite.closeConnection('noaction', false); + }); + + test(`${label}: a self-referencing tree cascades to every descendant and terminates`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'selfref', + `CREATE TABLE nodes ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE, + ${SYNC_COLUMNS} + );`, + `INSERT INTO nodes (id, parent_id) VALUES (1, NULL), (2, 1), (3, 2), (4, 3), (5, NULL), (6, 5);`, + ); + + await db.run('DELETE FROM nodes WHERE id = ?', [1]); + + // 1 -> 2 -> 3 -> 4 all go; the 5 -> 6 branch is untouched. This also exercises a column-level + // REFERENCES clause, which the port source's regex never matched. + expect(await marks(db, 'nodes')).toEqual({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 0, 6: 0 }); + await sqlite.closeConnection('selfref', false); + }); + + test(`${label}: every table referencing the parent is followed, not just the first`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'multiref', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE alpha ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE beta ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO alpha (id, parent_id) VALUES (10, 2); + INSERT INTO beta (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'alpha')).toEqual({ 10: 1 }); + expect(await marks(db, 'beta')).toEqual({ 20: 1 }); + await sqlite.closeConnection('multiref', false); + }); + + test(`${label}: a composite foreign key cascades on the whole key`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'composite', + `CREATE TABLE parents ( + org TEXT NOT NULL, code TEXT NOT NULL, ${SYNC_COLUMNS}, + PRIMARY KEY (org, code) + ); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, org TEXT, code TEXT, ${SYNC_COLUMNS}, + FOREIGN KEY (org, code) REFERENCES parents(org, code) ON DELETE CASCADE + );`, + `INSERT INTO parents (org, code) VALUES ('a', 'x'), ('a', 'y'); + INSERT INTO children (id, org, code) VALUES (1, 'a', 'x'), (2, 'a', 'y');`, + ); + + await db.run('DELETE FROM parents WHERE org = ? AND code = ?', ['a', 'x']); + // Only the row matching BOTH key columns goes. + expect(await marks(db, 'children')).toEqual({ 1: 1, 2: 0 }); + await sqlite.closeConnection('composite', false); + }); + + test(`${label}: a child without sql_deleted is left to sqlite's own enforcement`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'mixed', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE notes ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO notes (id, parent_id) VALUES (20, 2);`, + ); + + // There is no column to mark, and nothing is orphaned: the parent row physically remains. + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect((await db.query('SELECT count(*) AS n FROM notes')).values?.[0].n).toBe(1); + + // The constraint is still real. deleteExportedRows performs an actual DELETE, and that is + // where sqlite runs the cascade itself. The backdate is what makes the row reclaimable: the + // export stamp has to be strictly newer than the row's last_modified. + await db.createSyncTable(); + await db.execute('UPDATE parents SET last_modified = 1 WHERE id = 2;'); + await db.exportToJson('full'); + await db.deleteExportedRows(); + expect((await db.query('SELECT count(*) AS n FROM parents')).values?.[0].n).toBe(0); + expect((await db.query('SELECT count(*) AS n FROM notes')).values?.[0].n).toBe(0); + await sqlite.closeConnection('mixed', false); + }); + + test(`${label}: a cascaded soft delete reports the same changes as the equivalent hard delete`, async () => { + const { sqlite } = await startHarness(tier); + + // Same shape, no sync columns: sqlite performs the real cascade. + const hard = await open( + sqlite, + 'hardc', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2), (21, 2);`, + ); + const hardChanges = (await hard.run('DELETE FROM parents WHERE id = ?', [2])).changes?.changes; + await sqlite.closeConnection('hardc', false); + + const soft = await open( + sqlite, + 'softc', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2), (21, 2);`, + ); + const softChanges = (await soft.run('DELETE FROM parents WHERE id = ?', [2])).changes?.changes; + await sqlite.closeConnection('softc', false); + + // 1 parent + 2 children either way. Recording a deletion must not count differently from + // performing one. + expect(hardChanges).toBe(3); + expect(softChanges).toBe(3); + }); + + test(`${label}: deleting an already-deleted parent is a no-op, not a second cascade`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'idem', TREE_SCHEMA, TREE_ROWS); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + const before = await db.query('SELECT last_modified FROM children WHERE id = 20'); + const second = await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(second.changes?.changes).toBe(0); + const after = await db.query('SELECT last_modified FROM children WHERE id = 20'); + expect(after.values?.[0].last_modified).toBe(before.values?.[0].last_modified); + await sqlite.closeConnection('idem', false); + }); + + test(`${label}: a delete written with literals rather than binds still cascades`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'literals', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, name TEXT, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id, name) VALUES (1, 'keep'), (2, 'drop'); + INSERT INTO children (id, parent_id) VALUES (10, 1), (20, 2);`, + ); + + // The WHERE clause carries a string literal instead of a bind, which is ordinary SQL and used + // to produce a rewritten statement that did not parse. + await db.run("DELETE FROM parents WHERE name = 'drop'"); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1 }); + await sqlite.closeConnection('literals', false); + }); + + test(`${label}: a DELETE inside an execute batch is recorded, not performed`, async () => { + // Which entry point the app happened to use must not decide whether the server hears about + // the deletion. execute() used to hand the batch straight to sqlite, so a batched DELETE + // physically removed rows from a sync-tracked database while run() of the same statement + // marked them. + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'batch', TREE_SCHEMA, TREE_ROWS); + + await db.execute("DELETE FROM parents WHERE name = 'drop';"); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + expect(await marks(db, 'grandchildren')).toEqual({ 100: 0, 200: 1, 210: 1 }); + expect((await db.query('SELECT count(*) AS n FROM parents')).values?.[0].n).toBe(2); + + // The rest of a mixed batch still runs normally. + await db.execute("INSERT INTO parents (id, name) VALUES (3, 'later'); DELETE FROM parents WHERE id = 3;"); + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1, 3: 1 }); + await sqlite.closeConnection('batch', false); + }); + + test(`${label}: a table without sql_deleted is deleted from for real, even in a sync database`, async () => { + // syncEnabled is a property of the database, but the rewrite has to be a property of the + // table: rewriting here would produce SET sql_deleted = 1 on a table with no such column. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'pertable', + `CREATE TABLE tracked (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE plain (id INTEGER PRIMARY KEY NOT NULL, v TEXT);`, + `INSERT INTO tracked (id) VALUES (1); + INSERT INTO plain (id, v) VALUES (1, 'a'), (2, 'b');`, + ); + + const result = await db.run('DELETE FROM plain WHERE id = ?', [1]); + expect(result.changes?.changes).toBe(1); + expect((await db.query('SELECT id FROM plain ORDER BY id')).values).toEqual([{ id: 2 }]); + + // And the tracked table still behaves as a sync table. + await db.run('DELETE FROM tracked WHERE id = ?', [1]); + expect(await marks(db, 'tracked')).toEqual({ 1: 1 }); + await sqlite.closeConnection('pertable', false); + }); + + test(`${label}: a RESTRICT found mid-walk leaves nothing half-marked`, async () => { + // The cascade is a tree of UPDATEs, so it has to be all or nothing even when the caller opted + // out of the implicit transaction. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'atomic', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE middle ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE guarded ( + id INTEGER PRIMARY KEY NOT NULL, middle_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (middle_id) REFERENCES middle(id) ON DELETE RESTRICT + );`, + `INSERT INTO parents (id) VALUES (1); + INSERT INTO middle (id, parent_id) VALUES (10, 1); + INSERT INTO guarded (id, middle_id) VALUES (100, 10);`, + ); + + await expect(db.run('DELETE FROM parents WHERE id = ?', [1], false)).rejects.toThrow(/related items exist/i); + + // `middle` was marked before the walk reached the RESTRICT two levels down. All of it is back. + expect(await marks(db, 'parents')).toEqual({ 1: 0 }); + expect(await marks(db, 'middle')).toEqual({ 10: 0 }); + expect(await marks(db, 'guarded')).toEqual({ 100: 0 }); + await sqlite.closeConnection('atomic', false); + }); + + test(`${label}: a soft delete asked for its rows still hands them back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'returning', TREE_SCHEMA, TREE_ROWS); + + // The RETURNING clause used to land inside the generated WHERE, producing SQL that did not + // parse, so this whole shape was unusable on a sync-tracked database. + const result = await db.run('DELETE FROM parents WHERE id = ? RETURNING id, name', [2], true, 'all'); + expect(result.changes?.values).toEqual([{ id: 2, name: 'drop' }]); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + await sqlite.closeConnection('returning', false); + }); + + test(`${label}: a quoted table name cascades like any other`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'quoted', + `CREATE TABLE "order" (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE "order line" ( + id INTEGER PRIMARY KEY NOT NULL, order_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (order_id) REFERENCES "order"(id) ON DELETE CASCADE + );`, + `INSERT INTO "order" (id) VALUES (1); + INSERT INTO "order line" (id, order_id) VALUES (10, 1);`, + ); + + await db.run('DELETE FROM "order" WHERE id = ?', [1]); + expect(await marks(db, '"order"')).toEqual({ 1: 1 }); + expect(await marks(db, '"order line"')).toEqual({ 10: 1 }); + await sqlite.closeConnection('quoted', false); + }); + + test(`${label}: a delete with an OR stays idempotent, and the cascade agrees with it`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'ors', TREE_SCHEMA, TREE_ROWS); + + const first = await db.run('DELETE FROM parents WHERE id = ? OR name = ?', [2, 'nobody']); + expect(first.changes?.changes).toBe(5); // the parent, two children, two grandchildren + const stamp = (await db.query('SELECT last_modified FROM parents WHERE id = 2')).values?.[0].last_modified; + + // Repeating it must be a no-op. Without brackets round the caller's clause the guard applied + // to the last disjunct only, so this re-marked the row and moved its last_modified. + const second = await db.run('DELETE FROM parents WHERE id = ? OR name = ?', [2, 'nobody']); + expect(second.changes?.changes).toBe(0); + expect((await db.query('SELECT last_modified FROM parents WHERE id = 2')).values?.[0].last_modified).toBe(stamp); + await sqlite.closeConnection('ors', false); + }); + + test(`${label}: a foreign key added later is seen by the next delete`, async () => { + // DDL does not only arrive through execute(). A CREATE TABLE issued with run() used to leave + // the cached foreign-key graph in place, so the new constraint was invisible and its children + // were never marked. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'latefk', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS});`, + 'INSERT INTO parents (id) VALUES (1), (2);', + ); + // Populates the caches: syncEnabled true, foreign-key graph empty. + await db.run('DELETE FROM parents WHERE id = ?', [1]); + + await db.run( + `CREATE TABLE children (id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE)`, + ); + await db.run('INSERT INTO children (id, parent_id) VALUES (?, ?)', [20, 2]); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'children')).toEqual({ 20: 1 }); + await sqlite.closeConnection('latefk', false); + }); + + test(`${label}: foreign keys are enforced, which they were not before`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'fkon', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + ); + expect((await db.query('PRAGMA foreign_keys')).values?.[0].foreign_keys).toBe(1); + await expect(db.run('INSERT INTO children (id, parent_id) VALUES (?, ?)', [1, 999])).rejects.toThrow( + /FOREIGN KEY constraint failed/i, + ); + await sqlite.closeConnection('fkon', false); + }); +}); diff --git a/test/web/core.test.ts b/test/web/core.test.ts new file mode 100644 index 00000000..3f8941e5 --- /dev/null +++ b/test/web/core.test.ts @@ -0,0 +1,211 @@ +/** + * The M1 exit criteria, run against both tiers through the public wrappers: + * open / query / run / executeSet / transactions / upgrade statements / deleteDatabase / + * getDatabaseList, with rows as plain objects and BLOBs as Uint8Array. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: create, open, execute, query`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('core', false, 'no-encryption', 1, false); + await db.open(); + + const created = await db.execute(` + CREATE TABLE IF NOT EXISTS people (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, age INTEGER); + CREATE INDEX IF NOT EXISTS people_name ON people (name); + `); + expect(created.changes?.changes).toBeGreaterThanOrEqual(0); + + const inserted = await db.execute( + `INSERT INTO people (name, age) VALUES ('Alice', 41), ('Bob', 29), ('Carol', 35);`, + ); + expect(inserted.changes?.changes).toBe(3); + + const rows = await db.query('SELECT id, name, age FROM people ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'Alice', age: 41 }, + { id: 2, name: 'Bob', age: 29 }, + { id: 3, name: 'Carol', age: 35 }, + ]); + + const filtered = await db.query('SELECT name FROM people WHERE age > ?', [30]); + expect(filtered.values?.map((r: any) => r.name)).toEqual(['Alice', 'Carol']); + + await sqlite.closeConnection('core', false); + }); + + test(`${label}: run reports changes and lastId together`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('runs', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + const first = await db.run('INSERT INTO t (v) VALUES (?)', ['one']); + expect(first.changes?.changes).toBe(1); + expect(first.changes?.lastId).toBe(1); + + const second = await db.run('INSERT INTO t (v) VALUES (?)', ['two']); + expect(second.changes?.lastId).toBe(2); + + const updated = await db.run("UPDATE t SET v = 'x' WHERE id <= ?", [2]); + expect(updated.changes?.changes).toBe(2); + + const deleted = await db.run('DELETE FROM t WHERE id = ?', [1]); + expect(deleted.changes?.changes).toBe(1); + + await sqlite.closeConnection('runs', false); + }); + + test(`${label}: run with returnMode all returns the RETURNING rows`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('returning', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + const res = await db.run('INSERT INTO t (v) VALUES (?) RETURNING id, v', ['hello'], true, 'all'); + expect(res.changes?.changes).toBe(1); + expect(res.changes?.values).toEqual([{ id: 1, v: 'hello' }]); + + const none = await db.run('INSERT INTO t (v) VALUES (?)', ['quiet']); + expect(none.changes?.values).toBeUndefined(); + + await sqlite.closeConnection('returning', false); + }); + + test(`${label}: executeSet, including one statement per row`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('sets', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, a TEXT, b INTEGER);'); + + const res = await db.executeSet([ + { statement: 'INSERT INTO t (a, b) VALUES (?, ?)', values: ['first', 1] }, + { + statement: 'INSERT INTO t (a, b) VALUES (?, ?)', + values: [ + ['second', 2], + ['third', 3], + ], + }, + ]); + expect(res.changes?.changes).toBe(3); + expect(res.changes?.lastId).toBe(3); + + const rows = await db.query('SELECT a, b FROM t ORDER BY id'); + expect(rows.values).toEqual([ + { a: 'first', b: 1 }, + { a: 'second', b: 2 }, + { a: 'third', b: 3 }, + ]); + + await sqlite.closeConnection('sets', false); + }); + + test(`${label}: transactions commit and roll back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('tx', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + expect((await db.isTransactionActive()).result).toBe(false); + await db.beginTransaction(); + expect((await db.isTransactionActive()).result).toBe(true); + await db.run("INSERT INTO t (v) VALUES ('kept')", [], false); + await db.commitTransaction(); + expect((await db.isTransactionActive()).result).toBe(false); + + await db.beginTransaction(); + await db.run("INSERT INTO t (v) VALUES ('dropped')", [], false); + await db.rollbackTransaction(); + + const rows = await db.query('SELECT v FROM t'); + expect(rows.values?.map((r: any) => r.v)).toEqual(['kept']); + + await sqlite.closeConnection('tx', false); + }); + + test(`${label}: a failing statement inside execute rolls the whole batch back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('atomic', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT UNIQUE);'); + + await expect(db.execute(`INSERT INTO t (v) VALUES ('a'); INSERT INTO t (v) VALUES ('a');`)).rejects.toBeTruthy(); + + const rows = await db.query('SELECT count(*) AS n FROM t'); + expect(rows.values?.[0].n).toBe(0); + + await sqlite.closeConnection('atomic', false); + }); + + test(`${label}: executeTransaction from the wrapper`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('wrappertx', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + await db.executeTransaction([ + { statement: "INSERT INTO t (v) VALUES ('a')" }, + { statement: 'INSERT INTO t (v) VALUES (?)', values: ['b'] }, + ]); + + const rows = await db.query('SELECT v FROM t ORDER BY id'); + expect(rows.values?.map((r: any) => r.v)).toEqual(['a', 'b']); + + await sqlite.closeConnection('wrappertx', false); + }); + + test(`${label}: existence, table list and delete`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('存在', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE alpha (id INTEGER PRIMARY KEY NOT NULL); CREATE TABLE beta (id INTEGER);'); + + expect((await db.isExists()).result).toBe(true); + expect((await db.isDBOpen()).result).toBe(true); + expect((await db.isTable('alpha')).result).toBe(true); + expect((await db.isTable('missing')).result).toBe(false); + expect((await db.getTableList()).values).toEqual(['alpha', 'beta']); + + const list = await sqlite.getDatabaseList(); + expect(list.values).toContain('存在SQLite.db'); + + await db.delete(); + const after = await sqlite.getDatabaseList(); + expect(after.values).not.toContain('存在SQLite.db'); + + await sqlite.closeConnection('存在', false); + }); + + test(`${label}: data survives close and reopen`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('persist', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('durable');"); + await sqlite.closeConnection('persist', false); + + const again = await sqlite.createConnection('persist', false, 'no-encryption', 1, false); + await again.open(); + const rows = await again.query('SELECT v FROM t'); + expect(rows.values?.[0].v).toBe('durable'); + await sqlite.closeConnection('persist', false); + }); + + test(`${label}: getVersion reports user_version`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('versioned', false, 'no-encryption', 3, false); + await db.open(); + expect((await db.getVersion()).version).toBe(3); + await sqlite.closeConnection('versioned', false); + }); + + test(`${label}: echo`, async () => { + const { sqlite } = await startHarness(tier); + expect((await sqlite.echo('hi')).value).toBe('hi'); + }); +}); diff --git a/test/web/dist-worker.test.ts b/test/web/dist-worker.test.ts new file mode 100644 index 00000000..8a2d3948 --- /dev/null +++ b/test/web/dist-worker.test.ts @@ -0,0 +1,72 @@ +/** + * The other suites run the worker from TypeScript source through setSqliteWorkerFactory. This + * one runs the artefact that actually ships: `dist/web-worker.js`, a classic (non-module) + * worker that has to find `dist/sqlite3.wasm` sitting next to it with no bundler involved. + * + * Requires `npm run build` first. It fails rather than skipping if the build is missing, because + * a silently skipped packaging test is worse than no packaging test. + */ +import { afterEach, beforeAll, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +const WORKER_URL = '/dist/web-worker.js'; +const WASM_URL = '/dist/sqlite3.wasm'; + +let plugin: CapacitorSQLiteWeb | null = null; + +afterEach(async () => { + await plugin?.closeWebStore(); + plugin = null; +}); + +beforeAll(async () => { + const [worker, wasm] = await Promise.all([fetch(WORKER_URL), fetch(WASM_URL)]); + if (!worker.ok || !wasm.ok) { + throw new Error(`${WORKER_URL} / ${WASM_URL} are not built. Run "npm run build" before the web tests.`); + } + setSqliteWorkerFactory(() => new Worker(WORKER_URL)); +}); + +test('the shipped classic worker boots and resolves sqlite3.wasm beside itself', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'dist-check', + directory: '.dist-check', + }); + plugin = new CapacitorSQLiteWeb(); + const sqlite = new SQLiteConnection(plugin); + await sqlite.initWebStore(); + expect(plugin.getWebStoreTier()).toBe(1); + + const db = await sqlite.createConnection('shipped', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT); INSERT INTO t (v) VALUES ('built');"); + const rows = await db.query('SELECT v FROM t'); + expect(rows.values).toEqual([{ v: 'built' }]); + + // BLOBs and BigInt cross the real worker boundary too, not just the source one. + const payload = new Uint8Array([1, 2, 3, 250]); + await db.execute('CREATE TABLE b (p BLOB, n INTEGER);'); + await db.run('INSERT INTO b (p, n) VALUES (?, ?)', [payload, 9007199254740993n]); + const back = await db.query('SELECT p, n FROM b'); + expect(Array.from(back.values?.[0].p as Uint8Array)).toEqual([1, 2, 3, 250]); + expect(back.values?.[0].n).toBe(9007199254740993n); + + await sqlite.closeConnection('shipped', false); +}); + +test('the wasm is a separate asset, not inlined into the worker', async () => { + const [worker, wasm] = await Promise.all([fetch(WORKER_URL), fetch(WASM_URL)]); + const workerSource = await worker.text(); + const wasmBytes = new Uint8Array(await wasm.arrayBuffer()); + + expect(Array.from(wasmBytes.subarray(0, 4))).toEqual([0x00, 0x61, 0x73, 0x6d]); + expect(workerSource).not.toContain('data:application/octet-stream;base64'); + // Classic workers have no import.meta and no document; both would be fatal in here. + expect(workerSource).toContain('self.location.href'); + expect(workerSource).not.toContain('document.currentScript'); +}); diff --git a/test/web/fixtures/jeep-image.ts b/test/web/fixtures/jeep-image.ts new file mode 100644 index 00000000..e2e58403 --- /dev/null +++ b/test/web/fixtures/jeep-image.ts @@ -0,0 +1,46 @@ +/** + * A real jeep-sqlite database image, not a hand-rolled stand-in. + * + * Produced by driving the actual `` element (jeep-sqlite 2.8.0, sql.js) in Chromium + * and reading the bytes back out of its localforage store with raw IndexedDB, which is the same + * path the migration takes. `PRAGMA page_size = 512` was set before the schema purely to keep the + * fixture small; everything else is jeep's own output. + * + * CREATE TABLE legacy (id INTEGER PRIMARY KEY, name TEXT, payload BLOB) + * INSERT INTO legacy (name, payload) VALUES ('from jeep', x'0102ff') + * PRAGMA user_version = 3 + */ +const JEEP_IMAGE_BASE64 = + 'U1FMaXRlIGZvcm1hdCAzAAIAAQEAQCAgAAAAAgAAAAIAAAAAAAAAAAAAAAEAAAAEAAAAAAAAAAAAAAABAAAAAwAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAACAC52ig0AAAABAaAAAaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABeAQcXGRkBgRd0YWJsZWxlZ2FjeWxlZ2FjeQJDUkVBVEUg' + + 'VEFCTEUgbGVnYWN5IChpZCBJTlRFR0VSIFBSSU1BUlkgS0VZLCBuYW1lIFRFWFQsIHBheWxvYWQgQkxPQikNAAAAAQHuAAHuAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABABBAAfEmZyb20gamVlcAEC/w=='; + +export function jeepImage(): Uint8Array { + return Uint8Array.from(atob(JEEP_IMAGE_BASE64), (c) => c.charCodeAt(0)); +} + +/** The same image with its pages scribbled over but its header intact: fails integrity_check. */ +export function corruptedJeepImage(): Uint8Array { + const bytes = jeepImage(); + bytes.fill(0xab, 100); + return bytes; +} + +/** + * A half-written image: real header, but neither a whole number of pages nor a length the OPFS + * pool's importDb will accept. What an interrupted write to the legacy store would leave. + */ +export function truncatedJeepImage(): Uint8Array { + return jeepImage().slice(0, 600); +} diff --git a/test/web/fts5.test.ts b/test/web/fts5.test.ts new file mode 100644 index 00000000..98121274 --- /dev/null +++ b/test/web/fts5.test.ts @@ -0,0 +1,72 @@ +/** + * FTS5 on both tiers. Dropping sql.js is only defensible because sqlite-wasm's canonical build + * has FTS5 and sql.js does not, so this is a load-bearing assertion rather than a nicety. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: create virtual table, MATCH, snippet, bm25, prefix`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('search', false, 'no-encryption', 1, false); + await db.open(); + + await db.execute("CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize='unicode61');"); + await db.executeSet([ + { + statement: 'INSERT INTO docs (title, body) VALUES (?, ?)', + values: [ + ['Genesis', 'In the beginning God created the heaven and the earth'], + ['Exodus', 'And the LORD said unto Moses, Go unto the people'], + ['Psalms', 'The LORD is my shepherd; I shall not want'], + ], + }, + ]); + + const hits = await db.query( + "SELECT title, snippet(docs, 1, '[', ']', '...', 8) AS snip, rank FROM docs WHERE docs MATCH ? ORDER BY rank", + ['lord'], + ); + expect(hits.values?.map((r: any) => r.title).sort()).toEqual(['Exodus', 'Psalms']); + expect(hits.values?.[0].snip).toContain('['); + expect(typeof hits.values?.[0].rank).toBe('number'); + + const scored = await db.query('SELECT title, bm25(docs) AS score FROM docs WHERE docs MATCH ? ORDER BY score', [ + 'created OR shepherd', + ]); + expect(scored.values?.map((r: any) => r.title).sort()).toEqual(['Genesis', 'Psalms']); + + const prefix = await db.query('SELECT count(*) AS n FROM docs WHERE docs MATCH ?', ['shep*']); + expect(prefix.values?.[0].n).toBe(1); + + await sqlite.closeConnection('search', false); + }); + + test(`${label}: an FTS5 index survives close and reopen`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('searchpersist', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE VIRTUAL TABLE d USING fts5(body); INSERT INTO d (body) VALUES ('persisted text');"); + await sqlite.closeConnection('searchpersist', false); + + const again = await sqlite.createConnection('searchpersist', false, 'no-encryption', 1, false); + await again.open(); + const hits = await again.query('SELECT body FROM d WHERE d MATCH ?', ['persisted']); + expect(hits.values).toEqual([{ body: 'persisted text' }]); + await sqlite.closeConnection('searchpersist', false); + }); + + test(`${label}: JSON1 is available, which the M2 import/export port relies on`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('jsonfns', false, 'no-encryption', 1, false); + await db.open(); + const res = await db.query( + `SELECT json_extract('{"a":{"b":42}}', '$.a.b') AS v, json_array_length('[1,2,3]') AS n`, + ); + expect(res.values?.[0]).toEqual({ v: 42, n: 3 }); + await sqlite.closeConnection('jsonfns', false); + }); +}); diff --git a/test/web/harness.ts b/test/web/harness.ts new file mode 100644 index 00000000..baf361ba --- /dev/null +++ b/test/web/harness.ts @@ -0,0 +1,68 @@ +/** + * Shared harness for the web contract tests. + * + * Tests drive the real `SQLiteConnection` / `SQLiteDBConnection` wrappers from + * `src/definitions.ts` over the real facade, so the wrappers are part of the surface under test + * exactly as the M1 exit criteria require. + * + * The worker is supplied through `setSqliteWorkerFactory` rather than the shipped + * `dist/web-worker.js`, so the suite runs against TypeScript sources and exercises the + * documented escape hatch at the same time. + */ +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +export interface Harness { + plugin: CapacitorSQLiteWeb; + sqlite: SQLiteConnection; + tier: Tier; +} + +let poolCounter = 0; +let active: CapacitorSQLiteWeb | null = null; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +/** + * @param tier which durability tier to exercise. Tier 2 is forced through the documented test + * hook, which skips the VFS install rather than faking a failure inside sqlite-wasm. + * @param freshPool give this suite its own pool directory so tests in the same file do not see + * each other's databases. + */ +export async function startHarness(tier: Tier, freshPool = true): Promise { + // One worker at a time. Each holds a wasm heap and, on tier 1, the pool's access handles. + if (active) await active.closeWebStore(); + active = null; + poolCounter += 1; + setSqliteWebOptions({ + forceTier2: tier === 2, + poolName: freshPool ? `capacitor-sqlite-test-${poolCounter}` : 'capacitor-sqlite', + directory: freshPool ? `.capacitor-sqlite-test-${poolCounter}` : '.capacitor-sqlite', + }); + + const plugin = new CapacitorSQLiteWeb(); + const sqlite = new SQLiteConnection(plugin); + await sqlite.initWebStore(); + + active = plugin; + const selected = plugin.getWebStoreTier(); + if (selected !== tier) throw new Error(`harness expected tier ${tier} but got ${selected}`); + return { plugin, sqlite, tier }; +} + +/** Both tiers, so every contract test runs twice. */ +export const TIERS: Tier[] = [1, 2]; + +export function tierLabel(tier: Tier): string { + return tier === 1 ? 'tier 1 (opfs-sahpool)' : 'tier 2 (:memory: + IndexedDB)'; +} + +/** Release the worker held by the most recent startHarness call. */ +export async function stopHarness(): Promise { + if (active) await active.closeWebStore(); + active = null; +} diff --git a/test/web/json.test.ts b/test/web/json.test.ts new file mode 100644 index 00000000..e0825aac --- /dev/null +++ b/test/web/json.test.ts @@ -0,0 +1,252 @@ +/** + * The JSON pipeline: isJsonValid, importFromJson, exportToJson, and the two progress events. + * + * The int64 round trip is the one that matters most for web specifically. sqlite-wasm returns + * values above 2^53 as BigInt and `JSON.stringify` throws on those, so an export that did not + * handle it would produce an object the caller cannot serialise, and a naive Number() cast would + * silently corrupt exactly the values BigInt exists to protect. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const SCHEMA = [ + { column: 'id', value: 'INTEGER PRIMARY KEY NOT NULL' }, + { column: 'name', value: 'TEXT NOT NULL' }, + { column: 'size', value: 'INTEGER' }, +]; + +function payload(overrides: any = {}) { + return { + database: 'jsondb', + version: 1, + encrypted: false, + mode: 'full', + tables: [ + { + name: 'items', + schema: SCHEMA, + indexes: [{ name: 'items_name', value: 'name' }], + values: [ + [1, 'alpha', 10], + [2, 'beta', 20], + ], + }, + ], + ...overrides, + }; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: isJsonValid accepts a good object and rejects bad ones`, async () => { + const { sqlite } = await startHarness(tier); + expect((await sqlite.isJsonValid(JSON.stringify(payload()))).result).toBe(true); + // An unknown key anywhere invalidates the whole object; that is the documented contract. + expect((await sqlite.isJsonValid(JSON.stringify(payload({ nonsense: 1 })))).result).toBe(false); + expect((await sqlite.isJsonValid(JSON.stringify({ database: 'x' }))).result).toBe(true); + expect((await sqlite.isJsonValid(JSON.stringify({ database: 42 }))).result).toBe(false); + expect((await sqlite.isJsonValid('not json at all')).result).toBe(false); + // A value row narrower than the schema is rejected. + const short = payload(); + short.tables[0].values = [[1, 'alpha']] as any; + expect((await sqlite.isJsonValid(JSON.stringify(short))).result).toBe(false); + }); + + test(`${label}: importFromJson creates schema, indexes and data`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const progress: string[] = []; + await plugin.addListener('sqliteImportProgressEvent', (e: any) => progress.push(e.progress)); + + const result = await sqlite.importFromJson(JSON.stringify(payload())); + expect(result.changes?.changes).toBeGreaterThan(0); + expect(progress.length).toBeGreaterThan(0); + expect(progress.some((p) => /Import completed/i.test(p))).toBe(true); + + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta', size: 20 }, + ]); + expect((await db.getVersion()).version).toBe(1); + const indexes = await db.query("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'items_name'"); + expect(indexes.values?.length).toBe(1); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: partial mode inserts new rows and updates existing ones`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + + const partial = payload({ + mode: 'partial', + tables: [ + { + name: 'items', + schema: SCHEMA, + values: [ + [2, 'beta renamed', 22], + [3, 'gamma', 30], + ], + }, + ], + }); + await sqlite.importFromJson(JSON.stringify(partial)); + + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta renamed', size: 22 }, + { id: 3, name: 'gamma', size: 30 }, + ]); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: re-importing an unchanged partial payload reports no changes`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + const same = payload({ mode: 'partial' }); + const result = await sqlite.importFromJson(JSON.stringify(same)); + expect(result.changes?.changes).toBe(0); + }); + + test(`${label}: exportToJson full round trip through a fresh database`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const progress: string[] = []; + await plugin.addListener('sqliteExportProgressEvent', (e: any) => progress.push(e.progress)); + + await sqlite.importFromJson(JSON.stringify(payload())); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const exported = await db.exportToJson('full'); + expect(progress.length).toBeGreaterThan(0); + + const json = exported.export as any; + expect(json.database).toBe('jsondb'); + expect(json.mode).toBe('full'); + expect(json.version).toBe(1); + const table = json.tables.find((t: any) => t.name === 'items'); + expect(table.values).toEqual([ + [1, 'alpha', 10], + [2, 'beta', 20], + ]); + expect(table.schema.map((c: any) => c.column)).toEqual(['id', 'name', 'size']); + expect(table.indexes.map((i: any) => i.name)).toEqual(['items_name']); + // The whole point of the exercise: the caller can serialise what they were handed. + expect(() => JSON.stringify(exported.export)).not.toThrow(); + await sqlite.closeConnection('jsondb', false); + + // And it re-imports into an empty database as the same data. + const reimport = { ...json, database: 'jsonrt', overwrite: true }; + await sqlite.importFromJson(JSON.stringify(reimport)); + const copy = await sqlite.createConnection('jsonrt', false, 'no-encryption', 1, false); + await copy.open(); + const rows = await copy.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta', size: 20 }, + ]); + await sqlite.closeConnection('jsonrt', false); + }); + + test(`${label}: int64 above 2^53 survives export and re-import without precision loss`, async () => { + const { sqlite } = await startHarness(tier); + const big = 9007199254740993n; // 2^53 + 1 + const db = await sqlite.createConnection('bigjson', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE nums (id INTEGER PRIMARY KEY NOT NULL, v INTEGER);'); + await db.run('INSERT INTO nums (id, v) VALUES (?, ?)', [1, big]); + await db.run('INSERT INTO nums (id, v) VALUES (?, ?)', [2, 42]); + expect((await db.query('SELECT v FROM nums WHERE id = 1')).values?.[0].v).toBe(big); + + const exported = await db.exportToJson('full'); + const json = exported.export as any; + // BigInt cannot be serialised, so the exporter hands back the decimal string. A Number cast + // would have produced 9007199254740992 and lost the value silently. + const table = json.tables.find((t: any) => t.name === 'nums'); + expect(table.values).toEqual([ + [1, '9007199254740993'], + [2, 42], + ]); + const serialised = JSON.stringify(exported.export); + expect(serialised).toContain('9007199254740993'); + await sqlite.closeConnection('bigjson', false); + + // SQLite's INTEGER affinity turns that string back into the identical int64. + await sqlite.importFromJson(JSON.stringify({ ...json, database: 'bigjson2', overwrite: true })); + const copy = await sqlite.createConnection('bigjson2', false, 'no-encryption', 1, false); + await copy.open(); + const back = await copy.query('SELECT v, typeof(v) AS t FROM nums ORDER BY id'); + expect(back.values?.[0].t).toBe('integer'); + expect(back.values?.[0].v).toBe(big); + expect(back.values?.[1].v).toBe(42); + await sqlite.closeConnection('bigjson2', false); + }); + + test(`${label}: BLOBs round trip through JSON as number arrays`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('blobjson', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE b (id INTEGER PRIMARY KEY NOT NULL, payload BLOB);'); + await db.run('INSERT INTO b (id, payload) VALUES (?, ?)', [1, new Uint8Array([1, 2, 250])]); + const exported = await db.exportToJson('full'); + await sqlite.closeConnection('blobjson', false); + + const json = exported.export as any; + const table = json.tables.find((t: any) => t.name === 'b'); + // Uint8Array serialises to an object with numeric keys, which is what the importer revives. + const asArray = Array.from(Object.values(table.values[0][1] as any)); + expect(asArray).toEqual([1, 2, 250]); + + table.values[0][1] = asArray; + await sqlite.importFromJson(JSON.stringify({ ...json, database: 'blobjson2', overwrite: true })); + const copy = await sqlite.createConnection('blobjson2', false, 'no-encryption', 1, false); + await copy.open(); + const back = await copy.query('SELECT payload FROM b'); + expect(back.values?.[0].payload).toBeInstanceOf(Uint8Array); + expect(Array.from(back.values?.[0].payload as Uint8Array)).toEqual([1, 2, 250]); + await sqlite.closeConnection('blobjson2', false); + }); + + test(`${label}: views survive the round trip`, async () => { + const { sqlite } = await startHarness(tier); + const withView = payload({ views: [{ name: 'big_items', value: 'SELECT * FROM items WHERE size > 15' }] }); + await sqlite.importFromJson(JSON.stringify(withView)); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT name FROM big_items'); + expect(rows.values).toEqual([{ name: 'beta' }]); + const exported = (await db.exportToJson('full')).export as any; + expect(exported.views.map((v: any) => v.name)).toEqual(['big_items']); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: an encrypted payload is refused rather than half-imported`, async () => { + const { sqlite } = await startHarness(tier); + await expect(sqlite.importFromJson(JSON.stringify(payload({ encrypted: true })))).rejects.toThrow( + /not supported on the web platform/i, + ); + }); + + test(`${label}: importing a lower version than the database holds is refused`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload({ version: 3 }))); + await expect(sqlite.importFromJson(JSON.stringify(payload({ version: 2 })))).rejects.toThrow( + /Cannot import a version lower than 3/i, + ); + }); + + test(`${label}: exportToJson refuses partial mode without a sync table`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + await expect(db.exportToJson('partial')).rejects.toThrow(/No sync_table available/i); + await sqlite.closeConnection('jsondb', false); + }); +}); diff --git a/test/web/lifecycle.test.ts b/test/web/lifecycle.test.ts new file mode 100644 index 00000000..896604fd --- /dev/null +++ b/test/web/lifecycle.test.ts @@ -0,0 +1,257 @@ +/** + * Backgrounding and recovery (PLAN 6.7), plus the error shapes a developer actually meets. + * + * WKWebView invalidates OPFS access handles when the app is suspended. The gentle path closes + * every connection and pauses the VFS before that happens, then unpauses and reopens. The heavy + * path exists because the gentle one is unproven against real device suspension: when unpause + * cannot be reached, a fresh worker takes the pool over instead. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { workerLoadFailure } from '../../src/web/errors'; +import type { Tier } from '../../src/web/protocol'; +import { ownerLockName } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { TIERS, stopHarness, tierLabel } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; +let poolCounter = 0; + +async function boot(tier: Tier, suffix: string): Promise<{ plugin: CapacitorSQLiteWeb; pool: string }> { + poolCounter += 1; + const pool = `life-${poolCounter}-${suffix}`; + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName: pool, + directory: `.${pool}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + return { plugin, pool }; +} + +async function seeded(plugin: CapacitorSQLiteWeb, name: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT);'); + await db.run('INSERT INTO t (v) VALUES (?)', ['before']); + return db; +} + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); +}); + +describe('worker load failures name the right cause', () => { + test('a parse failure is reported as an unsupported browser, not a bundler problem', () => { + const err = workerLoadFailure('Uncaught SyntaxError: Unexpected token .'); + expect(err.message).toMatch(/below the minimum this plugin supports/i); + expect(err.message).toMatch(/Chrome\/Android WebView 80, Safari 14, Firefox 74/); + // The raw evidence survives: a guess that hides it is worse than no guess. + expect(err.message).toMatch(/Unexpected token \./); + expect(err.message).not.toMatch(/bundler/i); + expect(err.code).toBe('UNSUPPORTED_ENGINE'); + }); + + test('anything else points at the asset the bundler did not serve', () => { + const err = workerLoadFailure('Failed to load script'); + expect(err.message).toMatch(/dist\/web-worker\.js/); + expect(err.message).toMatch(/setSqliteWorkerFactory/); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + }); + + test('HTML served where the worker should be is a bundler problem, not an old browser', () => { + // What a 404 looks like from the browser: the dev server answers with index.html and the + // parser stops on its first tag. Measured on Chrome 150 with a Vite build (PLAN 18.1 P3). + for (const raw of ["Uncaught SyntaxError: Unexpected token '<'", 'SyntaxError: Unexpected token <']) { + const err = workerLoadFailure(raw); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + expect(err.message).toMatch(/returned HTML instead of JavaScript/i); + expect(err.message).toMatch(/setSqliteWorkerFactory/); + // The claim that would send a developer chasing a browser floor that is not the problem. + expect(err.message).not.toMatch(/below the minimum this plugin supports/i); + expect(err.message).toContain(raw); + } + }); + + test('a DOCTYPE in the message is read the same way', () => { + const err = workerLoadFailure('Uncaught SyntaxError: Unexpected token '); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + }); + + test('an empty browser message still produces a usable error', () => { + expect(workerLoadFailure('').message).toMatch(/no further detail from the browser/); + }); +}); + +describe('multi-tab ownership', () => { + test('a store whose owner lock is already held fails with the documented error', async () => { + poolCounter += 1; + const pool = `life-${poolCounter}-locked`; + let release: (() => void) | null = null; + const held = new Promise((resolve) => { + release = resolve; + }); + + // Take the lock the way another tab's worker would, and hold it. + const granted = new Promise((resolve) => { + void navigator.locks.request(ownerLockName(pool), async () => { + resolve(); + await held; + }); + }); + await granted; + + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName: pool, + directory: `.${pool}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow( + /open in another tab or window[\s\S]*single owning context per origin/i, + ); + // And it did not quietly become a tier 2 store on top of the other tab's data. + expect(plugin.getWebStoreTier()).toBeNull(); + + release?.(); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: pause closes everything and resume brings it back`, async () => { + const { plugin } = await boot(tier, 'pause'); + const db = await seeded(plugin, 'paused'); + + await plugin.pauseWebStore(); + // While paused the store is genuinely unavailable rather than quietly serving stale data. + if (tier === 1) await expect(db.query('SELECT v FROM t')).rejects.toThrow(); + + await plugin.resumeWebStore(); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + + // Writes work again afterwards, so the reopened connection is a real one. + await db.run('INSERT INTO t (v) VALUES (?)', ['after']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after' }]); + }); + + test(`${label}: pause and resume are idempotent`, async () => { + const { plugin } = await boot(tier, 'idem'); + const db = await seeded(plugin, 'idem'); + await plugin.pauseWebStore(); + await plugin.pauseWebStore(); + await plugin.resumeWebStore(); + await plugin.resumeWebStore(); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + }); + + test(`${label}: a resume that arrives before the pause has finished still restores the store`, async () => { + const { plugin } = await boot(tier, 'race'); + const db = await seeded(plugin, 'race'); + + // The device sequence from PLAN 18.4, reproduced without a device: iOS freezes the page + // inside pauseWebStore's worker round trip and delivers the foreground signal first, so the + // resume runs while the pause is still in flight and before it has published any state. + const pausing = plugin.pauseWebStore(); + const resuming = plugin.resumeWebStore(); + await Promise.all([pausing, resuming]); + + // Before the fix this read failed with "Database race is not open": the resume had returned a + // no-op and the pause then closed every connection with nothing left to reopen them. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + await db.run('INSERT INTO t (v) VALUES (?)', ['after the race']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after the race' }]); + }); + + test(`${label}: the store is usable again after a raced pause and resume, without a second cycle`, async () => { + const { plugin } = await boot(tier, 'race2'); + const db = await seeded(plugin, 'race2'); + + // Same race, driven the way the plugin's own listener drives it: neither call is awaited by + // the caller, which is what `void (going ? pause : resume)()` does in watchAppState. + void plugin.pauseWebStore(); + await plugin.resumeWebStore(); + + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + }); + + test(`${label}: a transaction open at pause time is rolled back and reported`, async () => { + const { plugin } = await boot(tier, 'txn'); + const db = await seeded(plugin, 'txn'); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + try { + await db.beginTransaction(); + await db.run('INSERT INTO t (v) VALUES (?)', ['uncommitted'], false); + await plugin.pauseWebStore(); + } finally { + console.warn = originalWarn; + } + await plugin.resumeWebStore(); + + if (tier === 1) { + expect(warnings.join(' ')).toMatch(/rolled back an open transaction on txn/i); + // Rolled back means rolled back: the uncommitted row is not there. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + } + }); + + test(`${label}: restartWebStore rebuilds the store and reopens what was open`, async () => { + const { plugin } = await boot(tier, 'restart'); + const db = await seeded(plugin, 'restart'); + + await plugin.restartWebStore(); + + expect(plugin.getWebStoreTier()).toBe(tier); + if (tier === 1) { + // Tier 1 is durable across a worker teardown; tier 2 keeps its image only to a flush point, + // which is why the assertion is scoped. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + } + }); +}); + +describe('recovery when the gentle path cannot run', () => { + test('a worker that dies while paused is replaced, and the data comes back', async () => { + const { plugin } = await boot(1, 'dead'); + const db = await seeded(plugin, 'dead'); + await plugin.pauseWebStore(); + + // What a real suspension can do behind our back: the worker is simply gone, so `unpause` + // never arrives and the heavy path is the only way back. + (plugin as any).client.terminate(); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + try { + await plugin.resumeWebStore(); + } finally { + console.warn = originalWarn; + } + + expect(warnings.join(' ')).toMatch(/restarting the worker/i); + expect(plugin.getWebStoreTier()).toBe(1); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + await db.run('INSERT INTO t (v) VALUES (?)', ['after recovery']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after recovery' }]); + }); +}); diff --git a/test/web/localdisk.test.ts b/test/web/localdisk.test.ts new file mode 100644 index 00000000..fe5d4dc7 --- /dev/null +++ b/test/web/localdisk.test.ts @@ -0,0 +1,166 @@ +/** + * getFromLocalDiskToStore and saveToLocalDisk. + * + * Both are DOM flows: one opens a file picker, the other triggers a download. Neither can be + * driven from an automated browser, because a picker needs a real user gesture and a download + * lands outside the page. The picker/download boundary is therefore injectable, and these tests + * replace it so that everything on either side of it, including the events, runs for real. What + * stays manual-only is the picker chrome itself; recorded in PLAN section 13. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { connectionNameFromFile, setSqliteLocalDiskAdapter } from '../../src/web/localdisk'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +afterEach(() => setSqliteLocalDiskAdapter(null)); + +describe('file name mapping (unit)', () => { + test('strips the plugin suffix and the extension', () => { + expect(connectionNameFromFile('mydbSQLite.db')).toBe('mydb'); + expect(connectionNameFromFile('mydb.db')).toBe('mydb'); + expect(connectionNameFromFile('mydb.sqlite')).toBe('mydb'); + expect(connectionNameFromFile('/downloads/mydb.sqlite3')).toBe('mydb'); + expect(connectionNameFromFile('plain')).toBe('plain'); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function exportBytes(sqlite: any, plugin: any, name: string, value: string): Promise { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await db.run('INSERT INTO t (v) VALUES (?)', [value]); + await sqlite.closeConnection(name, false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: name }); + return bytes; + } + + test(`${label}: getFromLocalDiskToStore adopts the picked file and raises its event`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const bytes = await exportBytes(sqlite, plugin, 'pickSource', 'from disk'); + const events: any[] = []; + await plugin.addListener('sqlitePickDatabaseEndedEvent', (e: any) => events.push(e)); + + setSqliteLocalDiskAdapter({ + pickDatabase: async () => ({ name: 'restored.db', bytes }), + saveDatabase: async () => undefined, + }); + + await sqlite.getFromLocalDiskToStore(true); + expect(events.length).toBe(1); + expect(events[0].db_name).toBe('restoredSQLite.db'); + expect(events[0].message).toBe('ended'); + + const db = await sqlite.createConnection('restored', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('from disk'); + await sqlite.closeConnection('restored', false); + }); + + test(`${label}: a cancelled picker reports it and changes nothing`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const events: any[] = []; + await plugin.addListener('sqlitePickDatabaseEndedEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async () => undefined, + }); + + await sqlite.getFromLocalDiskToStore(true); + expect(events.length).toBe(1); + expect(events[0].message).toMatch(/cancel/i); + expect(events[0].db_name).toBeUndefined(); + }); + + test(`${label}: overwrite = false leaves an existing database alone`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const original = await exportBytes(sqlite, plugin, 'keepMine', 'mine'); + const incoming = await exportBytes(sqlite, plugin, 'theirs', 'theirs'); + + setSqliteLocalDiskAdapter({ + pickDatabase: async () => ({ name: 'keepMine.db', bytes: incoming }), + saveDatabase: async () => undefined, + }); + expect(original.byteLength).toBeGreaterThan(0); + + await sqlite.getFromLocalDiskToStore(false); + const db = await sqlite.createConnection('keepMine', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('mine'); + await sqlite.closeConnection('keepMine', false); + }); + + test(`${label}: saveToLocalDisk hands over a valid database file and raises its event`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const db = await sqlite.createConnection('saveme', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('saved');"); + + const saved: { name: string; bytes: Uint8Array }[] = []; + const events: any[] = []; + await plugin.addListener('sqliteSaveDatabaseToDiskEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async (name, bytes) => { + saved.push({ name, bytes }); + }, + }); + + await sqlite.saveToLocalDisk('saveme'); + + expect(saved.length).toBe(1); + expect(saved[0].name).toBe('savemeSQLite.db'); + expect(new TextDecoder().decode(saved[0].bytes.subarray(0, 15))).toBe('SQLite format 3'); + expect(events.length).toBe(1); + expect(events[0].message).toBe('ended'); + await sqlite.closeConnection('saveme', false); + }); + + test(`${label}: a failing save reports the error on the event and rejects`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const db = await sqlite.createConnection('savefail', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (v TEXT);'); + + const events: any[] = []; + await plugin.addListener('sqliteSaveDatabaseToDiskEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async () => { + throw new Error('disk full'); + }, + }); + + await expect(sqlite.saveToLocalDisk('savefail')).rejects.toThrow(/SaveToLocalDisk: disk full/); + expect(events[0].message).toMatch(/^Error:.*disk full/); + await sqlite.closeConnection('savefail', false); + }); + + test(`${label}: a saved database can be picked straight back in`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('roundtrip', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('there and back');"); + + let captured: Uint8Array | null = null; + setSqliteLocalDiskAdapter({ + pickDatabase: async () => (captured ? { name: 'copy.db', bytes: captured } : null), + saveDatabase: async (_name, bytes) => { + captured = bytes; + }, + }); + + await sqlite.saveToLocalDisk('roundtrip'); + await sqlite.closeConnection('roundtrip', false); + expect(captured).not.toBeNull(); + + await sqlite.getFromLocalDiskToStore(true); + const copy = await sqlite.createConnection('copy', false, 'no-encryption', 1, false); + await copy.open(); + expect((await copy.query('SELECT v FROM t')).values?.[0].v).toBe('there and back'); + await sqlite.closeConnection('copy', false); + }); +}); diff --git a/test/web/migrate-jeep.test.ts b/test/web/migrate-jeep.test.ts new file mode 100644 index 00000000..83b6a5ad --- /dev/null +++ b/test/web/migrate-jeep.test.ts @@ -0,0 +1,320 @@ +/** + * The one-time import of jeep-sqlite's IndexedDB store. + * + * The fixtures are shaped like the real thing rather than like the plan's description of it: the + * legacy database is at version 2 with localforage's own `local-forage-detect-blob-support` store + * beside `databases`, a never-saved database is a key with an undefined value, and an interrupted + * upgrade leaves a `backup-SQLite.db` copy behind. All three were observed by driving + * jeep-sqlite 2.8.0 in this same browser; the database image in `fixtures/jeep-image.ts` is its + * actual output. + * + * Everything runs in one file because the legacy store is origin-wide and vitest browser mode + * gives each test FILE its own storage partition. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { JEEP_DB_NAME, JEEP_STORE_NAME, databaseNameFromKey } from '../../src/web/worker/migrate-jeep'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { corruptedJeepImage, jeepImage, truncatedJeepImage } from './fixtures/jeep-image'; +import { TIERS, stopHarness, tierLabel } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const BLOB_SUPPORT_STORE = 'local-forage-detect-blob-support'; + +function idb(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +/** Recreate what localforage leaves behind: version 2, two object stores. */ +function openLegacy(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(JEEP_DB_NAME, 2); + req.onupgradeneeded = () => { + for (const store of [JEEP_STORE_NAME, BLOB_SUPPORT_STORE]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('could not seed the legacy store')); + }); +} + +async function seedLegacy(entries: Record): Promise { + const db = await openLegacy(); + const store = db.transaction(JEEP_STORE_NAME, 'readwrite').objectStore(JEEP_STORE_NAME); + // Every put is issued before the first await: a transaction goes inactive as soon as control + // returns to the event loop with nothing pending. + await Promise.all(Object.entries(entries).map(([key, value]) => idb(store.put(value, key)))); + db.close(); +} + +/** Null when the legacy database no longer exists at all. */ +async function legacyKeys(): Promise { + const present = (await (indexedDB as any).databases()).some((entry: any) => entry?.name === JEEP_DB_NAME); + if (!present) return null; + const db = await openLegacy(); + const store = db.transaction(JEEP_STORE_NAME, 'readonly').objectStore(JEEP_STORE_NAME); + const keys = await idb(store.getAllKeys()); + db.close(); + return keys.map(String); +} + +function dropLegacy(): Promise { + return new Promise((resolve) => { + const req = indexedDB.deleteDatabase(JEEP_DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} + +const open: CapacitorSQLiteWeb[] = []; + +/** A store of its own, so the migration marker starts unset for every test. */ +async function boot(tier: Tier, poolName: string, skipJeepMigration = false): Promise { + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + poolName, + directory: `.${poolName}`, + skipJeepMigration, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(tier); + return plugin; +} + +beforeEach(async () => { + await dropLegacy(); +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); + await dropLegacy(); +}); + +describe('jeep key naming', () => { + test.each([ + ['jeepdemoSQLite.db', 'jeepdemo'], + ['legacy.db', 'legacy'], + ['plain', 'plain'], + ])('%s belongs to the database %s', (key, database) => { + expect(databaseNameFromKey(key)).toBe(database); + }); +}); + +describe.each(TIERS)('jeep-sqlite migration on %s', (tier) => { + const label = tierLabel(tier); + const pool = (suffix: string) => `jeep-${tier}-${suffix}`; + + test(`imports every real database and retires the legacy store (${label})`, async () => { + await seedLegacy({ + 'jeepdemoSQLite.db': jeepImage(), + // An interrupted version upgrade leaves this behind. It is a copy of a database that is + // already present under its own key, so importing it would conjure a phantom `backup-jeepdemo`. + 'backup-jeepdemoSQLite.db': jeepImage(), + // Opened by jeep but never saved: the key exists, the value does not. + 'neversavedSQLite.db': undefined, + }); + + const plugin = await boot(tier, pool('happy')); + const migration = plugin.getJeepMigration(); + expect(migration?.migrated).toEqual(['jeepdemo']); + expect(migration?.failed).toEqual([]); + expect(migration?.skipped.sort()).toEqual(['backup-jeepdemoSQLite.db', 'neversavedSQLite.db']); + expect(migration?.warning).toBeUndefined(); + + // An orphan backup key must not stand in the way of retiring the store. + expect(migration?.legacyStoreDeleted).toBe(true); + expect(await legacyKeys()).toBeNull(); + + const list = await plugin.getDatabaseList(); + expect(list.values).toEqual(['jeepdemoSQLite.db']); + + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection('jeepdemo', false, 'no-encryption', 3, false); + await db.open(); + // The data, the schema version and the BLOB all survive the move. + expect((await db.query('SELECT id, name, payload FROM legacy')).values).toEqual([ + { id: 1, name: 'from jeep', payload: new Uint8Array([1, 2, 255]) }, + ]); + expect((await db.getVersion()).version).toBe(3); + // And it is a live database, not a read-only snapshot. + await db.run('INSERT INTO legacy (name) VALUES (?)', ['written after migrating']); + expect((await db.query('SELECT count(*) AS n FROM legacy')).values?.[0].n).toBe(2); + await sqlite.closeConnection('jeepdemo', false); + }); + + test(`a store holding nothing but placeholders is still retired (${label})`, async () => { + await seedLegacy({ 'neversavedSQLite.db': undefined, 'emptySQLite.db': new Uint8Array(0) }); + const plugin = await boot(tier, pool('placeholders')); + expect(plugin.getJeepMigration()?.migrated).toEqual([]); + expect(plugin.getJeepMigration()?.legacyStoreDeleted).toBe(true); + expect(await legacyKeys()).toBeNull(); + }); + + test(`nothing happens when there is no legacy store (${label})`, async () => { + const plugin = await boot(tier, pool('absent')); + expect(plugin.getJeepMigration()).toBeNull(); + // The probe must not create the database it went looking for. + expect(await legacyKeys()).toBeNull(); + }); + + test(`it never runs a second time (${label})`, async () => { + await seedLegacy({ 'firstSQLite.db': jeepImage() }); + const first = await boot(tier, pool('once')); + expect(first.getJeepMigration()?.migrated).toEqual(['first']); + await first.closeWebStore(); + + // A legacy store that reappears after the marker is set is left alone: on a real installation + // it can only be data the app has already moved past, and re-importing it would overwrite + // whatever the app has written since. + await seedLegacy({ 'secondSQLite.db': jeepImage() }); + const second = await boot(tier, pool('once')); + expect(second.getJeepMigration()).toBeNull(); + expect(await legacyKeys()).toEqual(['secondSQLite.db']); + expect((await second.getDatabaseList()).values).toEqual(['firstSQLite.db']); + }); + + test(`a database that will not verify leaves the whole legacy store in place (${label})`, async () => { + await seedLegacy({ + 'goodSQLite.db': jeepImage(), + 'corruptSQLite.db': corruptedJeepImage(), + 'garbageSQLite.db': new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + }); + + const plugin = await boot(tier, pool('broken')); + const migration = plugin.getJeepMigration(); + expect(migration?.failed.sort()).toEqual(['corrupt', 'garbage']); + expect(migration?.warning).toMatch(/left in place/i); + expect(migration?.legacyStoreDeleted).toBe(false); + // Not one legacy byte is thrown away while anything remains unmigrated. + expect((await legacyKeys())?.sort()).toEqual(['corruptSQLite.db', 'garbageSQLite.db', 'goodSQLite.db']); + + // The database that did import is usable, and the ones that did not were rolled back rather + // than left in the store as unreadable files. + expect(migration?.migrated).toEqual(['good']); + expect((await plugin.getDatabaseList()).values).toEqual(['goodSQLite.db']); + }); + + test(`a half-written image is refused on both tiers (${label})`, async () => { + await seedLegacy({ 'cutSQLite.db': truncatedJeepImage() }); + const plugin = await boot(tier, pool('truncated')); + const migration = plugin.getJeepMigration(); + expect(migration?.migrated).toEqual([]); + expect(migration?.failed).toEqual(['cut']); + expect(migration?.legacyStoreDeleted).toBe(false); + expect(await legacyKeys()).toEqual(['cutSQLite.db']); + // Whatever the tier did with it, nothing readable is left behind under that name. + expect((await plugin.getDatabaseList()).values).toEqual([]); + }); + + test(`a partial failure is not retried on the next boot (${label})`, async () => { + await seedLegacy({ 'goodSQLite.db': jeepImage(), 'garbageSQLite.db': new Uint8Array([1, 2, 3, 4]) }); + const first = await boot(tier, pool('noretry')); + expect(first.getJeepMigration()?.migrated).toEqual(['good']); + expect(first.getJeepMigration()?.warning).toMatch(/not be retried/i); + + // The app now writes to the database it just got back. + const sqlite = new SQLiteConnection(first); + const db = await sqlite.createConnection('good', false, 'no-encryption', 3, false); + await db.open(); + await db.run("INSERT INTO legacy (name) VALUES ('written after migrating')"); + await sqlite.closeConnection('good', false); + await first.closeWebStore(); + + // Re-importing the legacy image here would silently undo that write, which costs more than + // the one database it would recover. + const second = await boot(tier, pool('noretry')); + expect(second.getJeepMigration()).toBeNull(); + const sqlite2 = new SQLiteConnection(second); + const db2 = await sqlite2.createConnection('good', false, 'no-encryption', 3, false); + await db2.open(); + expect((await db2.query('SELECT count(*) AS n FROM legacy')).values?.[0].n).toBe(2); + await sqlite2.closeConnection('good', false); + }); + + test(`two keys claiming one database do not overwrite each other (${label})`, async () => { + // `fooSQLite.db` is jeep's own key format; a bare `foo` is what jeep's setPathSuffix leaves + // behind for a picked file with no extension. Both resolve to the same target here, so the + // second one must be refused rather than silently written over the first. + await seedLegacy({ 'fooSQLite.db': jeepImage(), foo: jeepImage() }); + + const plugin = await boot(tier, pool('collision')); + const migration = plugin.getJeepMigration(); + expect(migration?.failed).toEqual(['foo']); + expect(migration?.warning).toMatch(/both claim it/i); + expect(migration?.legacyStoreDeleted).toBe(false); + expect((await legacyKeys())?.sort()).toEqual(['foo', 'fooSQLite.db']); + }); + + test(`a value that cannot be decoded fails rather than being taken for a placeholder (${label})`, async () => { + // Not one of the shapes the migration knows how to read. Treating it as jeep's never-saved + // placeholder would drop it and then delete the store holding the only copy. + await seedLegacy({ 'oddSQLite.db': { looksLike: 'nothing we can read' } }); + const plugin = await boot(tier, pool('undecodable')); + const migration = plugin.getJeepMigration(); + expect(migration?.skipped).toEqual([]); + expect(migration?.failed).toEqual(['odd']); + expect(migration?.warning).toMatch(/not readable as bytes/i); + expect(await legacyKeys()).toEqual(['oddSQLite.db']); + }); + + test(`a backup key without its primary is a database in its own right (${label})`, async () => { + // jeep only writes backup-x while x exists. On its own the key is somebody's database, quite + // possibly one they really did call backup-2024. + await seedLegacy({ 'backup-2024SQLite.db': jeepImage() }); + const plugin = await boot(tier, pool('lonebackup')); + expect(plugin.getJeepMigration()?.migrated).toEqual(['backup-2024']); + expect(plugin.getJeepMigration()?.skipped).toEqual([]); + expect((await plugin.getDatabaseList()).values).toEqual(['backup-2024SQLite.db']); + }); + + test(`a live database of the same name is never written over (${label})`, async () => { + // What an app that ran with skipJeepMigration on, and then turned it off, looks like. + await seedLegacy({ 'notesSQLite.db': jeepImage() }); + const skipped = await boot(tier, pool('liveclash'), true); + const sqlite = new SQLiteConnection(skipped); + const db = await sqlite.createConnection('notes', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE current (id INTEGER PRIMARY KEY, note TEXT);'); + await db.run('INSERT INTO current (note) VALUES (?)', ['written on the new engine']); + await sqlite.closeConnection('notes', false); + await skipped.closeWebStore(); + + const migrating = await boot(tier, pool('liveclash')); + const migration = migrating.getJeepMigration(); + expect(migration?.migrated).toEqual([]); + expect(migration?.failed).toEqual(['notes']); + expect(migration?.warning).toMatch(/already in the store/i); + expect(await legacyKeys()).toEqual(['notesSQLite.db']); + + // The live database is intact: not overwritten by the legacy image, and not discarded either. + const sqlite2 = new SQLiteConnection(migrating); + const db2 = await sqlite2.createConnection('notes', false, 'no-encryption', 1, false); + await db2.open(); + expect((await db2.query('SELECT note FROM current')).values).toEqual([{ note: 'written on the new engine' }]); + await sqlite2.closeConnection('notes', false); + }); + + test(`skipJeepMigration leaves the legacy store untouched (${label})`, async () => { + await seedLegacy({ 'jeepdemoSQLite.db': jeepImage() }); + const plugin = await boot(tier, pool('skip'), true); + expect(plugin.getJeepMigration()).toBeNull(); + expect(await legacyKeys()).toEqual(['jeepdemoSQLite.db']); + expect((await plugin.getDatabaseList()).values).toEqual([]); + }); +}); diff --git a/test/web/promote.test.ts b/test/web/promote.test.ts new file mode 100644 index 00000000..4056375e --- /dev/null +++ b/test/web/promote.test.ts @@ -0,0 +1,232 @@ +/** + * Tier promotion (PLAN 14.8): databases stored as IndexedDB images while the browser lacked OPFS + * move into the pool once it gains it. + * + * Without this, the normal upgrade path for a tier 2 user (Android WebView reaching M132, iOS 16.3 + * to 16.4) ends with an empty store and their data stranded in IndexedDB. Every test here boots + * the same pool name twice, once forced onto tier 2 and once not, which is exactly what that + * upgrade looks like from the plugin's side. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { IMAGE_STORE_NAME, IMAGE_STORE_VERSION, META_STORE_NAME, imageStoreDbName } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { jeepImage } from './fixtures/jeep-image'; +import { stopHarness } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; +let poolCounter = 0; + +async function boot(tier: Tier, poolName: string): Promise { + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName, + directory: `.${poolName}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(tier); + return plugin; +} + +/** Create a database with one row, on whichever tier the plugin is currently on. */ +async function seedDatabase(plugin: CapacitorSQLiteWeb, name: string, note: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, note TEXT);'); + await db.run('INSERT INTO notes (note) VALUES (?)', [note]); + await sqlite.closeConnection(name, false); + // Tier 2 only writes the image at a flush point, and closeConnection is one. +} + +async function noteIn(plugin: CapacitorSQLiteWeb, name: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT note FROM notes ORDER BY id'); + await sqlite.closeConnection(name, false); + return (rows.values ?? []).map((row: any) => row.note); +} + +function idb(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +function openImageStore(poolName: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(imageStoreDbName(poolName), IMAGE_STORE_VERSION); + req.onupgradeneeded = () => { + for (const store of [IMAGE_STORE_NAME, META_STORE_NAME]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('could not open the image store')); + }); +} + +/** Put bytes straight into the fallback store, for images the plugin would never write itself. */ +async function putImage(poolName: string, storage: string, bytes: Uint8Array): Promise { + const db = await openImageStore(poolName); + const store = db.transaction(IMAGE_STORE_NAME, 'readwrite').objectStore(IMAGE_STORE_NAME); + await idb(store.put(bytes, storage)); + db.close(); +} + +async function imageKeys(poolName: string): Promise { + const db = await openImageStore(poolName); + const store = db.transaction(IMAGE_STORE_NAME, 'readonly').objectStore(IMAGE_STORE_NAME); + const keys = await idb(store.getAllKeys()); + db.close(); + return keys.map(String).sort(); +} + +beforeEach(() => { + poolCounter += 1; +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); +}); + +describe('tier promotion', () => { + const pool = (suffix: string) => `promote-${poolCounter}-${suffix}`; + + test('databases written on tier 2 are in the pool after the browser gains OPFS', async () => { + const name = pool('clean'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'alpha', 'written on tier 2'); + await seedDatabase(fallback, 'beta', 'also tier 2'); + expect(await imageKeys(name)).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + await fallback.closeWebStore(); + + const upgraded = await boot(1, name); + const promotion = upgraded.getTierPromotion(); + expect(promotion?.promoted.sort()).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + expect(promotion?.failed).toEqual([]); + expect(promotion?.conflicts).toEqual([]); + expect(promotion?.warning).toBeUndefined(); + + // Visible, readable, and with their rows intact. + expect((await upgraded.getDatabaseList()).values.sort()).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + expect(await noteIn(upgraded, 'alpha')).toEqual(['written on tier 2']); + expect(await noteIn(upgraded, 'beta')).toEqual(['also tier 2']); + + // The image store is empty now, so the next boot has nothing to do. + expect(await imageKeys(name)).toEqual([]); + }); + + test('an unreadable image fails alone and the rest still move', async () => { + const name = pool('partial'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'good', 'survives'); + await fallback.closeWebStore(); + await putImage(name, 'badSQLite.db', new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + + const upgraded = await boot(1, name); + const promotion = upgraded.getTierPromotion(); + expect(promotion?.promoted).toEqual(['goodSQLite.db']); + expect(promotion?.failed).toEqual(['badSQLite.db']); + expect(promotion?.warning).toMatch(/retried on the next start/i); + + // The good one moved and the bad one is still where it was: nothing is thrown away. + expect((await upgraded.getDatabaseList()).values).toEqual(['goodSQLite.db']); + expect(await imageKeys(name)).toEqual(['badSQLite.db']); + expect(await noteIn(upgraded, 'good')).toEqual(['survives']); + }); + + test('a partial failure resumes safely on the next boot', async () => { + const name = pool('resume'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'good', 'from tier 2'); + await fallback.closeWebStore(); + await putImage(name, 'badSQLite.db', new Uint8Array([9, 9, 9, 9])); + + const first = await boot(1, name); + expect(first.getTierPromotion()?.promoted).toEqual(['goodSQLite.db']); + // The app then writes to the database it just got back. + const sqlite = new SQLiteConnection(first); + const db = await sqlite.createConnection('good', false, 'no-encryption', 1, false); + await db.open(); + await db.run('INSERT INTO notes (note) VALUES (?)', ['written after promoting']); + await sqlite.closeConnection('good', false); + await first.closeWebStore(); + + const second = await boot(1, name); + // The bad image is retried, the good one is not touched again, and the post-promotion write + // is still there. Re-adopting it would have silently undone that row. + expect(second.getTierPromotion()?.promoted).toEqual([]); + expect(second.getTierPromotion()?.failed).toEqual(['badSQLite.db']); + expect(await noteIn(second, 'good')).toEqual(['from tier 2', 'written after promoting']); + }); + + test('the pool wins a name conflict and the losing image is kept, not deleted', async () => { + const name = pool('conflict'); + // A pool database exists first, then an image of the same name appears in the fallback store. + // Only an interrupted promotion can produce this, which is why the image is not assumed stale + // enough to throw away. + const store = await boot(1, name); + await seedDatabase(store, 'notes', 'the pool copy'); + await store.closeWebStore(); + await putImage(name, 'notesSQLite.db', jeepImage()); + + const again = await boot(1, name); + const promotion = again.getTierPromotion(); + expect(promotion?.conflicts).toEqual(['notesSQLite.db']); + expect(promotion?.promoted).toEqual([]); + expect(promotion?.warning).toMatch(/share a name/i); + + // The pool copy is the one in use, unchanged, and the image is still in IndexedDB. + expect(await noteIn(again, 'notes')).toEqual(['the pool copy']); + expect(await imageKeys(name)).toEqual(['notesSQLite.db']); + }); + + test('a tier 2 boot after a failed promotion still sees what was not moved', async () => { + const name = pool('backdown'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'moved', 'promotable'); + await seedDatabase(fallback, 'stuck', 'also promotable'); + await fallback.closeWebStore(); + + // Make one of them unreadable so its promotion fails. + await putImage(name, 'stuckSQLite.db', new Uint8Array([1, 2, 3, 4])); + + const upgraded = await boot(1, name); + expect(upgraded.getTierPromotion()?.promoted).toEqual(['movedSQLite.db']); + expect(upgraded.getTierPromotion()?.failed).toEqual(['stuckSQLite.db']); + await upgraded.closeWebStore(); + + // Back on tier 2, whatever did not move is exactly where it was. + const back = await boot(2, name); + expect(back.getTierPromotion()).toBeNull(); + expect((await back.getDatabaseList()).values).toEqual(['stuckSQLite.db']); + }); + + test('tier 2 never promotes, and an empty fallback store reports nothing', async () => { + const name = pool('quiet'); + const onTier2 = await boot(2, name); + await seedDatabase(onTier2, 'alpha', 'stays put'); + // Promotion is a tier 1 pass by definition: on tier 2 the images ARE the databases. + expect(onTier2.getTierPromotion()).toBeNull(); + await onTier2.closeWebStore(); + + const clean = await boot(1, pool('empty')); + expect(clean.getTierPromotion()).toBeNull(); + }); +}); diff --git a/test/web/readonly.test.ts b/test/web/readonly.test.ts new file mode 100644 index 00000000..19b76be4 --- /dev/null +++ b/test/web/readonly.test.ts @@ -0,0 +1,104 @@ +/** + * Read-only connections are real on web now. README.md documented them as unsupported under + * jeep-sqlite; tier 1 opens the file with sqlite's read-only flag and tier 2, which must open + * `:memory:` writable so it can deserialize, enforces it with PRAGMA query_only. + * + * The RO_/RW_ registry split has to keep working either way, because + * SQLiteConnection._connectionDict keys on exactly that. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function seed(sqlite: any, name: string) { + const rw = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await rw.open(); + await rw.execute("CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT); INSERT INTO t (v) VALUES ('seeded');"); + await sqlite.closeConnection(name, false); + } + + test(`${label}: a read-only connection can read`, async () => { + const { sqlite } = await startHarness(tier); + await seed(sqlite, 'ro'); + const ro = await sqlite.createConnection('ro', false, 'no-encryption', 1, true); + await ro.open(); + const rows = await ro.query('SELECT v FROM t'); + expect(rows.values?.[0].v).toBe('seeded'); + expect((await ro.isDBOpen()).result).toBe(true); + await sqlite.closeConnection('ro', true); + }); + + test(`${label}: the engine refuses a write on a read-only connection`, async () => { + const { sqlite, plugin } = await startHarness(tier); + await seed(sqlite, 'roguard'); + const ro = await sqlite.createConnection('roguard', false, 'no-encryption', 1, true); + await ro.open(); + + // Going through the wrapper is refused before it reaches the plugin. + await expect(ro.execute("INSERT INTO t (v) VALUES ('nope')")).rejects.toMatch(/read-only/i); + + // Going straight at the plugin has to be refused by sqlite itself. + await expect( + plugin.query({ database: 'roguard', statement: "INSERT INTO t (v) VALUES ('nope')", values: [], readonly: true }), + ).rejects.toThrow(/readonly|read-only|read only/i); + + const rows = await ro.query('SELECT count(*) AS n FROM t'); + expect(rows.values?.[0].n).toBe(1); + await sqlite.closeConnection('roguard', true); + }); + + test(`${label}: RO and RW connections to one database coexist in the registry`, async () => { + const { sqlite } = await startHarness(tier); + await seed(sqlite, 'both'); + + const rw = await sqlite.createConnection('both', false, 'no-encryption', 1, false); + await rw.open(); + const ro = await sqlite.createConnection('both', false, 'no-encryption', 1, true); + await ro.open(); + + expect((await sqlite.isConnection('both', false)).result).toBe(true); + expect((await sqlite.isConnection('both', true)).result).toBe(true); + expect((await sqlite.checkConnectionsConsistency()).result).toBe(true); + + await rw.run('INSERT INTO t (v) VALUES (?)', ['from rw']); + expect((await rw.query('SELECT count(*) AS n FROM t')).values?.[0].n).toBe(2); + + await sqlite.closeConnection('both', true); + await sqlite.closeConnection('both', false); + }); + + test(`${label}: opening a database that does not exist read-only fails`, async () => { + const { sqlite } = await startHarness(tier); + const ro = await sqlite.createConnection('ghost', false, 'no-encryption', 1, true); + await expect(ro.open()).rejects.toBeTruthy(); + await sqlite.closeConnection('ghost', true).catch(() => undefined); + }); + + test(`${label}: checkConnectionsConsistency closes what the caller no longer knows about`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const a = await sqlite.createConnection('cc-a', false, 'no-encryption', 1, false); + await a.open(); + const b = await sqlite.createConnection('cc-b', false, 'no-encryption', 1, false); + await b.open(); + + // The plugin holds two; the caller claims only one. + const res = await plugin.checkConnectionsConsistency({ dbNames: ['cc-a'], openModes: ['RW'] }); + expect(res.result).toBe(true); + expect((await plugin.isDBOpen({ database: 'cc-a', readonly: false })).result).toBe(true); + await expect(plugin.isDBOpen({ database: 'cc-b', readonly: false })).rejects.toThrow(/No available connection/); + + await sqlite.closeConnection('cc-a', false).catch(() => undefined); + }); + + test(`${label}: an empty claim set resets everything and reports false`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const a = await sqlite.createConnection('cc-reset', false, 'no-encryption', 1, false); + await a.open(); + const res = await plugin.checkConnectionsConsistency({ dbNames: [], openModes: [] }); + expect(res.result).toBe(false); + await expect(plugin.isDBOpen({ database: 'cc-reset', readonly: false })).rejects.toThrow(/No available connection/); + }); +}); diff --git a/test/web/statements.test.ts b/test/web/statements.test.ts new file mode 100644 index 00000000..b5b4fbac --- /dev/null +++ b/test/web/statements.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for the statement scanner. Semicolons inside string literals, comments and trigger + * bodies must not split a statement, which is exactly what the string-replace approach in + * electron-utils could not guarantee. + */ +import { describe, expect, test } from 'vitest'; + +import { + hasReturningClause, + producesRows, + replaceUndefinedByNull, + splitStatements, + statementKind, + stripNoise, +} from '../../src/web/worker/statements'; + +describe('splitStatements', () => { + test('splits a plain batch and drops empty fragments', () => { + expect(splitStatements('CREATE TABLE t (a); INSERT INTO t VALUES (1); ;')).toEqual([ + 'CREATE TABLE t (a)', + 'INSERT INTO t VALUES (1)', + ]); + }); + + test('a semicolon inside a string literal is data, not a separator', () => { + expect(splitStatements("INSERT INTO t VALUES ('a;b'); SELECT 1")).toEqual([ + "INSERT INTO t VALUES ('a;b')", + 'SELECT 1', + ]); + }); + + test('doubled quotes inside a literal are escapes', () => { + expect(splitStatements("INSERT INTO t VALUES ('it''s; fine'); SELECT 2")).toEqual([ + "INSERT INTO t VALUES ('it''s; fine')", + 'SELECT 2', + ]); + }); + + test('quoted identifiers and bracket identifiers are opaque', () => { + expect(splitStatements('SELECT "a;b" FROM [c;d]; SELECT 3')).toEqual(['SELECT "a;b" FROM [c;d]', 'SELECT 3']); + }); + + test('comments cannot end a statement', () => { + expect(splitStatements('SELECT 1 -- trailing; comment\n; SELECT 2')).toEqual([ + 'SELECT 1 -- trailing; comment', + 'SELECT 2', + ]); + expect(splitStatements('SELECT 1 /* block ; comment */; SELECT 2')).toEqual([ + 'SELECT 1 /* block ; comment */', + 'SELECT 2', + ]); + }); + + test('a trigger body stays in one piece', () => { + const sql = `CREATE TRIGGER t_ins AFTER INSERT ON t BEGIN UPDATE t SET seen = 1; DELETE FROM u; END; SELECT 1;`; + const parts = splitStatements(sql); + expect(parts).toHaveLength(2); + expect(parts[0]).toContain('DELETE FROM u'); + expect(parts[0].endsWith('END')).toBe(true); + expect(parts[1]).toBe('SELECT 1'); + }); + + test('a comment-only batch produces nothing', () => { + expect(splitStatements('-- nothing here\n/* nor here */')).toEqual([]); + }); +}); + +describe('inspection helpers', () => { + test('statementKind reads the leading keyword', () => { + expect(statementKind(' insert into t values (1)')).toBe('INSERT'); + expect(statementKind('/* lead */ SELECT 1')).toBe('SELECT'); + expect(statementKind('')).toBe(''); + }); + + test('hasReturningClause ignores the word inside a literal', () => { + expect(hasReturningClause('INSERT INTO t VALUES (1) RETURNING id')).toBe(true); + expect(hasReturningClause("INSERT INTO t VALUES ('returning')")).toBe(false); + }); + + test('producesRows covers reads and RETURNING writes', () => { + expect(producesRows('SELECT 1')).toBe(true); + expect(producesRows('WITH c AS (SELECT 1) SELECT * FROM c')).toBe(true); + expect(producesRows('PRAGMA user_version')).toBe(true); + expect(producesRows('INSERT INTO t VALUES (1)')).toBe(false); + expect(producesRows('DELETE FROM t RETURNING id')).toBe(true); + }); + + test('stripNoise blanks literals and comments', () => { + expect(stripNoise("SELECT 'x' -- y\n, 1").includes('x')).toBe(false); + }); + + test('replaceUndefinedByNull only touches undefined', () => { + expect(replaceUndefinedByNull([1, undefined, null, 'a'])).toEqual([1, null, null, 'a']); + expect(replaceUndefinedByNull(undefined)).toEqual([]); + }); +}); diff --git a/test/web/sync.test.ts b/test/web/sync.test.ts new file mode 100644 index 00000000..3dd5e707 --- /dev/null +++ b/test/web/sync.test.ts @@ -0,0 +1,248 @@ +/** + * Sync tables and the soft-delete convention (PLAN 2.4). + * + * A database opts in by giving its tables both `last_modified` and `sql_deleted`. From then on + * a DELETE marks the row instead of removing it, so the next export can tell the server about + * it, and `deleteExportedRows` is what finally removes rows a completed export already carried. + */ +import { describe, expect, test } from 'vitest'; + +import { extractTableName, extractWhereClause, softDeleteRewrite } from '../../src/web/worker/statements'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const SYNC_SCHEMA = ` + CREATE TABLE items ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + last_modified INTEGER DEFAULT (strftime('%s','now')), + sql_deleted BOOLEAN DEFAULT 0 + );`; + +describe('soft-delete rewrite (unit)', () => { + test('leaves statements alone when the database does not participate', () => { + expect(softDeleteRewrite('DELETE FROM t WHERE id = 1', false)).toBe('DELETE FROM t WHERE id = 1'); + }); + + test('rewrites a DELETE into a guarded UPDATE', () => { + expect(softDeleteRewrite('DELETE FROM items WHERE id = 3;', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE (id = 3) AND sql_deleted = 0;', + ); + }); + + test('only touches DELETE', () => { + for (const sql of ['SELECT * FROM items', 'UPDATE items SET name = ?', 'INSERT INTO items VALUES (1)']) { + expect(softDeleteRewrite(sql, true)).toBe(sql); + } + }); + + test('a DELETE with no WHERE clause is an error rather than a silent full-table update', () => { + expect(() => softDeleteRewrite('DELETE FROM items', true)).toThrow(/WHERE/i); + }); + + test('table and where extraction ignore string literals', () => { + expect(extractTableName("DELETE FROM items WHERE name = 'DELETE FROM other'")).toBe('items'); + // The literal is blanked before matching, so ORDER BY still terminates the clause. + expect(extractWhereClause('DELETE FROM items WHERE id = 4 ORDER BY id')).toBe('id = 4'); + }); + + test('the clause keeps its literals, and a WHERE inside one is not mistaken for the real one', () => { + // Blanking the literal to find the keyword is right; returning the blanked text is not. This + // used to yield "name =", which rewrites to SQL that does not parse. + expect(extractWhereClause("DELETE FROM items WHERE name = 'bob'")).toBe("name = 'bob'"); + expect(softDeleteRewrite("DELETE FROM items WHERE name = 'bob';", true)).toBe( + "UPDATE items SET sql_deleted = 1 WHERE (name = 'bob') AND sql_deleted = 0;", + ); + // A literal containing the keyword must not become the clause. + expect(extractWhereClause("DELETE FROM items WHERE note = 'WHERE id = 1' AND id = 2")).toBe( + "note = 'WHERE id = 1' AND id = 2", + ); + // Comments are blanked in place, so what follows them is still found at the right offset. + expect(extractWhereClause('DELETE FROM items /* drop it */ WHERE id = 7')).toBe('id = 7'); + }); + + test('a quoted table name is the table, not the next keyword', () => { + // stripNoise blanks double-quoted identifiers along with string literals, so a greedy match + // over the blanked copy read straight past the name: this returned "WHERE", the rewrite + // targeted a table of that name, and the soft delete silently became a real one. + expect(extractTableName('DELETE FROM "order" WHERE id = ?')).toBe('"order"'); + expect(extractTableName('DELETE FROM [order] WHERE id = ?')).toBe('[order]'); + expect(extractTableName('DELETE FROM items WHERE id = ?')).toBe('items'); + expect(softDeleteRewrite('DELETE FROM "order" WHERE id = ?', true)).toBe( + 'UPDATE "order" SET sql_deleted = 1 WHERE (id = ?) AND sql_deleted = 0;', + ); + }); + + test('the clause is bracketed, because AND binds tighter than OR', () => { + // Unbracketed, this reads as `id = 1 OR (id = 2 AND sql_deleted = 0)`: the guard covers only + // the last disjunct, so a repeated delete marks row 1 again and bumps its last_modified. + expect(softDeleteRewrite('DELETE FROM items WHERE id = 1 OR id = 2', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE (id = 1 OR id = 2) AND sql_deleted = 0;', + ); + }); + + test('RETURNING survives the rewrite instead of ending up inside the WHERE clause', () => { + expect(extractWhereClause('DELETE FROM items WHERE id = 1 RETURNING *')).toBe('id = 1'); + expect(softDeleteRewrite('DELETE FROM items WHERE id = 1 RETURNING *', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE (id = 1) AND sql_deleted = 0 RETURNING *;', + ); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function seeded(sqlite: any, name: string) { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute(SYNC_SCHEMA); + await db.execute("INSERT INTO items (id, name) VALUES (1, 'one'), (2, 'two'), (3, 'three');"); + return db; + } + + test(`${label}: createSyncTable requires the sync columns`, async () => { + const { sqlite } = await startHarness(tier); + const plain = await sqlite.createConnection('plain', false, 'no-encryption', 1, false); + await plain.open(); + await plain.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await expect(plain.createSyncTable()).rejects.toThrow(/last_modified\/sql_deleted/i); + await sqlite.closeConnection('plain', false); + }); + + test(`${label}: createSyncTable is idempotent and sets a sync date`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'syncdb'); + const first = await db.createSyncTable(); + expect(first.changes?.changes).toBeGreaterThan(0); + const second = await db.createSyncTable(); + expect(second.changes?.changes).toBe(0); + + // The wrapper hands back an ISO string, not the raw seconds the plugin returns. + const date = await db.getSyncDate(); + expect(typeof date).toBe('string'); + expect(Date.parse(date)).toBeGreaterThan(0); + await sqlite.closeConnection('syncdb', false); + }); + + test(`${label}: setSyncDate and getSyncDate round trip`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'syncdate'); + await db.createSyncTable(); + await db.setSyncDate('2026-01-02T03:04:05.000Z'); + const date = await db.getSyncDate(); + expect(date).toBe('2026-01-02T03:04:05.000Z'); + await sqlite.closeConnection('syncdate', false); + }); + + test(`${label}: DELETE soft-deletes once the sync columns exist`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'softdel'); + await db.createSyncTable(); + + const deleted = await db.run('DELETE FROM items WHERE id = ?', [2]); + expect(deleted.changes?.changes).toBe(1); + + // The row is still there, marked. + const all = await db.query('SELECT id, sql_deleted FROM items ORDER BY id'); + expect(all.values).toEqual([ + { id: 1, sql_deleted: 0 }, + { id: 2, sql_deleted: 1 }, + { id: 3, sql_deleted: 0 }, + ]); + + // And deleting it again is a no-op rather than churning last_modified. + const again = await db.run('DELETE FROM items WHERE id = ?', [2]); + expect(again.changes?.changes).toBe(0); + await sqlite.closeConnection('softdel', false); + }); + + test(`${label}: a database without the sync columns still hard-deletes`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('harddel', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await db.execute("INSERT INTO t (id, v) VALUES (1, 'a'), (2, 'b');"); + await db.run('DELETE FROM t WHERE id = ?', [1]); + const rows = await db.query('SELECT id FROM t'); + expect(rows.values).toEqual([{ id: 2 }]); + await sqlite.closeConnection('harddel', false); + }); + + test(`${label}: deleteExportedRows physically removes what an export carried away`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'exported'); + await db.createSyncTable(); + await db.run('DELETE FROM items WHERE id = ?', [2]); + expect((await db.query('SELECT count(*) AS n FROM items')).values?.[0].n).toBe(3); + + // Without an export there is no last-export date, so there is nothing to reclaim. + await expect(db.deleteExportedRows()).rejects.toThrow(/no last exported date/i); + + // Exporting stamps sync_table id 2, which is what makes the rows reclaimable. The stamp has + // to be strictly newer than the rows' last_modified, hence the explicit backdate. + await db.execute('UPDATE items SET last_modified = 1 WHERE id = 2;'); + await db.exportToJson('full'); + + await db.deleteExportedRows(); + const rows = await db.query('SELECT id FROM items ORDER BY id'); + expect(rows.values).toEqual([{ id: 1 }, { id: 3 }]); + await sqlite.closeConnection('exported', false); + }); + + test(`${label}: partial export carries only rows modified since the sync date`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'partialexp'); + await db.createSyncTable(); + // Backdate everything, then move the sync date between the old rows and a new one. + await db.execute('UPDATE items SET last_modified = 1000;'); + await db.setSyncDate(new Date(2000 * 1000).toISOString()); + await db.run('INSERT INTO items (id, name, last_modified) VALUES (?, ?, ?)', [4, 'four', 3000]); + + const exported = (await db.exportToJson('partial')).export as any; + expect(exported.mode).toBe('partial'); + const table = exported.tables.find((t: any) => t.name === 'items'); + expect(table.values.map((row: any[]) => row[0])).toEqual([4]); + await sqlite.closeConnection('partialexp', false); + }); + + test(`${label}: sync_table is not itself exported`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'nosynctable'); + await db.createSyncTable(); + const exported = (await db.exportToJson('full')).export as any; + expect(exported.tables.map((t: any) => t.name)).not.toContain('sync_table'); + await sqlite.closeConnection('nosynctable', false); + }); + + test(`${label}: an importFromJson row marked sql_deleted deletes the local row`, async () => { + const { sqlite } = await startHarness(tier); + await seeded(sqlite, 'importdel'); + await sqlite.closeConnection('importdel', false); + + const payload = { + database: 'importdel', + version: 1, + encrypted: false, + mode: 'partial', + tables: [ + { + name: 'items', + schema: [ + { column: 'id', value: 'INTEGER PRIMARY KEY NOT NULL' }, + { column: 'name', value: 'TEXT NOT NULL' }, + { column: 'last_modified', value: "INTEGER DEFAULT (strftime('%s','now'))" }, + { column: 'sql_deleted', value: 'BOOLEAN DEFAULT 0' }, + ], + values: [[3, 'three', 1234, 1]], + }, + ], + }; + await sqlite.importFromJson(JSON.stringify(payload)); + + const again = await sqlite.createConnection('importdel', false, 'no-encryption', 1, false); + await again.open(); + const rows = await again.query('SELECT id FROM items ORDER BY id'); + expect(rows.values).toEqual([{ id: 1 }, { id: 2 }]); + await sqlite.closeConnection('importdel', false); + }); +}); diff --git a/test/web/tiers.test.ts b/test/web/tiers.test.ts new file mode 100644 index 00000000..330ce57a --- /dev/null +++ b/test/web/tiers.test.ts @@ -0,0 +1,127 @@ +/** + * Tier selection, which is the part of this implementation that can lose data if it guesses. + * + * A busy pool and an unsupported platform produce rejections that look alike; only the first + * must fail loudly. Falling back on a busy pool would open an empty `:memory:` database over the + * user's real data and then flush that empty image over the stored one. + * + * Everything here lives in one file on purpose: vitest browser mode gives each test FILE its own + * storage partition, so a second owning context has to be a second worker inside one file. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { stopHarness } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; + +afterEach(async () => { + await Promise.all(open.splice(0).map((p) => p.closeWebStore())); + await stopHarness(); +}); + +function makePlugin(): CapacitorSQLiteWeb { + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + return plugin; +} + +describe('tier selection', () => { + test('a healthy environment selects tier 1 without cross-origin isolation', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-happy', + directory: '.tiers-happy', + }); + const plugin = makePlugin(); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(1); + expect(self.crossOriginIsolated).toBe(false); + }); + + test('a second owning context is refused, not downgraded to tier 2', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-shared', + directory: '.tiers-shared', + }); + const first = makePlugin(); + await new SQLiteConnection(first).initWebStore(); + expect(first.getWebStoreTier()).toBe(1); + + const second = makePlugin(); + await expect(new SQLiteConnection(second).initWebStore()).rejects.toThrow(/another tab or window/i); + // The critical assertion: it did NOT silently become a tier 2 store. + expect(second.getWebStoreTier()).toBeNull(); + }); + + test('releasing the store lets the next context take over', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-handover', + directory: '.tiers-handover', + }); + const first = makePlugin(); + await new SQLiteConnection(first).initWebStore(); + await first.closeWebStore(); + + const second = makePlugin(); + await new SQLiteConnection(second).initWebStore(); + expect(second.getWebStoreTier()).toBe(1); + }); + + test('a busy-pool rejection that slips past the lock still fails loudly', async () => { + setSqliteWebOptions({ + forceTier2: false, + poolName: 'tiers-busy', + directory: '.tiers-busy', + simulateInstallError: 'NoModificationAllowedError', + }); + const plugin = makePlugin(); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow(/another tab or window/i); + expect(plugin.getWebStoreTier()).toBeNull(); + }); + + test('an unrecognised rejection fails loudly rather than guessing', async () => { + setSqliteWebOptions({ + forceTier2: false, + poolName: 'tiers-weird', + directory: '.tiers-weird', + simulateInstallError: 'SomethingNobodyPlannedFor', + }); + const plugin = makePlugin(); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow(/opfs-sahpool/i); + expect(plugin.getWebStoreTier()).toBeNull(); + }); + + test.each(['Missing required OPFS APIs.', 'The local OPFS API is too old for opfs-sahpool'])( + 'a genuine capability gap (%s) falls back to tier 2', + async (message) => { + setSqliteWebOptions({ + forceTier2: false, + poolName: `tiers-gap-${message.length}`, + directory: `.tiers-gap-${message.length}`, + simulateInstallError: message, + }); + const plugin = makePlugin(); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(2); + }, + ); + + test('methods refuse to run before initWebStore', async () => { + const plugin = new CapacitorSQLiteWeb(); + await expect(plugin.createConnection({ database: 'nope' })).rejects.toThrow(/initWebStore/); + await expect(plugin.getDatabaseList()).rejects.toThrow(/initWebStore/); + }); +}); diff --git a/test/web/types.test.ts b/test/web/types.test.ts new file mode 100644 index 00000000..36f4d048 --- /dev/null +++ b/test/web/types.test.ts @@ -0,0 +1,128 @@ +/** + * The result-shape contract from PLAN 6.3, which the rest of the plugin depends on: + * plain row objects, `Uint8Array` for BLOBs, and int64 as BigInt. + */ +import { describe, expect, test } from 'vitest'; + +import { CapacitorSQLiteWeb } from '../../src/web'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: BLOBs stay Uint8Array in and out, byte for byte`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('blobs', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE b (id INTEGER PRIMARY KEY NOT NULL, name TEXT, payload BLOB);'); + + for (const size of [0, 1, 15, 512, 65536]) { + const source = new Uint8Array(size); + crypto.getRandomValues(source); + await db.run('INSERT INTO b (name, payload) VALUES (?, ?)', [`s${size}`, source]); + + const rows = await db.query( + 'SELECT payload, length(payload) AS len, typeof(payload) AS t FROM b WHERE name = ?', + [`s${size}`], + ); + const row = rows.values?.[0]; + expect(row.t).toBe('blob'); + expect(row.len).toBe(size); + expect(row.payload).toBeInstanceOf(Uint8Array); + expect(Array.from(row.payload as Uint8Array)).toEqual(Array.from(source)); + } + + await sqlite.closeConnection('blobs', false); + }); + + test(`${label}: rows are plain objects and reorderRows leaves them alone`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('shapes', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (a INTEGER, b TEXT, c REAL, d BLOB); INSERT INTO t VALUES (1, 'x', 2.5, NULL);"); + + const rows = await db.query('SELECT a, b, c, d FROM t'); + const row = rows.values?.[0]; + // reorderRows only rewrites a result whose first row carries the iOS `ios_columns` key. + // Web must never produce that, so the helper has to be a no-op here. + expect(Object.getPrototypeOf(row)).toBe(Object.prototype); + expect(Object.keys(row)).toEqual(['a', 'b', 'c', 'd']); + expect(row).toEqual({ a: 1, b: 'x', c: 2.5, d: null }); + expect(rows.values?.some((r: any) => 'ios_columns' in r)).toBe(false); + + // And directly against the helper the wrapper runs every query through: given web results + // it has to hand back the identical object, not a rebuilt one. + const reorderRows = (db as any).reorderRows.bind(db); + const before = { values: [{ a: 1, b: 'x' }] }; + const after = await reorderRows(before); + expect(after).toBe(before); + expect(after.values).toEqual([{ a: 1, b: 'x' }]); + + // Contrast: an iOS-shaped result is the only thing it rewrites. + const iosShaped = { values: [{ ios_columns: ['b', 'a'] }, { a: 1, b: 'x' }] }; + const iosResult = await reorderRows(iosShaped); + expect(iosResult.values).toEqual([{ b: 'x', a: 1 }]); + + await sqlite.closeConnection('shapes', false); + }); + + test(`${label}: int64 above 2^53 round-trips as BigInt without losing precision`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('bigints', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v INTEGER);'); + + const big = 9007199254740993n; // 2^53 + 1, not representable as a JS number + await db.run('INSERT INTO t (v) VALUES (?)', [big]); + await db.run('INSERT INTO t (v) VALUES (?)', [42]); + + const rows = await db.query('SELECT v, typeof(v) AS t FROM t ORDER BY id'); + expect(rows.values?.[0].t).toBe('integer'); + expect(typeof rows.values?.[0].v).toBe('bigint'); + expect(rows.values?.[0].v).toBe(big); + // Small integers still come back as plain numbers, so ordinary code is unaffected. + expect(typeof rows.values?.[1].v).toBe('number'); + expect(rows.values?.[1].v).toBe(42); + + // Documented consequence: JSON.stringify cannot serialise this, which is why the M2 + // exportToJson port has to be BigInt-aware. + expect(() => JSON.stringify(rows.values)).toThrow(TypeError); + + await sqlite.closeConnection('bigints', false); + }); + + test(`${label}: undefined bind values become NULL rather than throwing`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('undef', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (a TEXT, b TEXT);'); + await db.run('INSERT INTO t (a, b) VALUES (?, ?)', ['set', undefined]); + const rows = await db.query('SELECT a, b FROM t'); + expect(rows.values?.[0]).toEqual({ a: 'set', b: null }); + await sqlite.closeConnection('undef', false); + }); + + test(`${label}: errors keep their message instead of being stringified`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('errors', false, 'no-encryption', 1, false); + await db.open(); + await expect(db.query('SELECT * FROM nope')).rejects.toThrow(/no such table: nope/); + // The jeep facade produced "Error: Error: ..." here by interpolating the Error itself. + await db.query('SELECT 1 AS ok').catch(() => undefined); + try { + await db.query('SELECT * FROM nope'); + throw new Error('expected a rejection'); + } catch (err) { + expect((err as Error).message.startsWith('Error:')).toBe(false); + } + await sqlite.closeConnection('errors', false); + }); +}); + +test('the facade is a WebPlugin and implements the plugin surface', async () => { + const plugin = new CapacitorSQLiteWeb(); + for (const method of ['initWebStore', 'createConnection', 'open', 'query', 'run', 'execute', 'executeSet']) { + expect(typeof (plugin as any)[method]).toBe('function'); + } +}); diff --git a/test/web/upgrades.test.ts b/test/web/upgrades.test.ts new file mode 100644 index 00000000..344098de --- /dev/null +++ b/test/web/upgrades.test.ts @@ -0,0 +1,131 @@ +/** + * addUpgradeStatement plus the version ladder that runs on open, ported from + * electron-utils/utilsUpgrade.ts. The interesting cases are the ones the electron port had to + * take a file backup for: a partially applied ladder must leave the database exactly as it was. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const V1 = ['CREATE TABLE notes (id INTEGER PRIMARY KEY NOT NULL, body TEXT NOT NULL);']; +const V2 = ['ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0;']; +const V3 = [ + 'CREATE TABLE tags (id INTEGER PRIMARY KEY NOT NULL, label TEXT);', + 'CREATE INDEX tags_label ON tags(label);', +]; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: the ladder runs in order and lands on the requested version`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('ladder', [ + { toVersion: 3, statements: V3 }, + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const db = await sqlite.createConnection('ladder', false, 'no-encryption', 3, false); + await db.open(); + + expect((await db.getVersion()).version).toBe(3); + expect((await db.getTableList()).values).toEqual(['notes', 'tags']); + await db.run('INSERT INTO notes (body, pinned) VALUES (?, ?)', ['hello', 1]); + expect((await db.query('SELECT pinned FROM notes')).values?.[0].pinned).toBe(1); + + await sqlite.closeConnection('ladder', false); + }); + + test(`${label}: reopening at a higher version applies only the new steps`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('stepwise', [{ toVersion: 1, statements: V1 }]); + const first = await sqlite.createConnection('stepwise', false, 'no-encryption', 1, false); + await first.open(); + await first.run('INSERT INTO notes (body) VALUES (?)', ['survivor']); + expect((await first.getVersion()).version).toBe(1); + await sqlite.closeConnection('stepwise', false); + + await sqlite.addUpgradeStatement('stepwise', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const second = await sqlite.createConnection('stepwise', false, 'no-encryption', 2, false); + await second.open(); + expect((await second.getVersion()).version).toBe(2); + // The v1 statements must not have run again, and the existing row must still be there. + const rows = await second.query('SELECT body, pinned FROM notes'); + expect(rows.values).toEqual([{ body: 'survivor', pinned: 0 }]); + + await sqlite.closeConnection('stepwise', false); + }); + + test(`${label}: opening below the stored version applies nothing`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('nodowngrade', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const first = await sqlite.createConnection('nodowngrade', false, 'no-encryption', 2, false); + await first.open(); + await sqlite.closeConnection('nodowngrade', false); + + const second = await sqlite.createConnection('nodowngrade', false, 'no-encryption', 1, false); + await second.open(); + expect((await second.getVersion()).version).toBe(2); + await sqlite.closeConnection('nodowngrade', false); + }); + + test(`${label}: a failing upgrade restores the database and reports the failure`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('broken', [{ toVersion: 1, statements: V1 }]); + const first = await sqlite.createConnection('broken', false, 'no-encryption', 1, false); + await first.open(); + await first.run('INSERT INTO notes (body) VALUES (?)', ['precious']); + await sqlite.closeConnection('broken', false); + + await sqlite.addUpgradeStatement('broken', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: ['ALTER TABLE notes ADD COLUMN pinned INTEGER;', 'THIS IS NOT SQL;'] }, + ]); + const second = await sqlite.createConnection('broken', false, 'no-encryption', 2, false); + await expect(second.open()).rejects.toThrow(/onUpgrade/i); + + // Reopen at the old version: the schema and the data must be exactly as they were. + await sqlite.closeConnection('broken', false); + await sqlite.addUpgradeStatement('broken', [{ toVersion: 1, statements: V1 }]); + const third = await sqlite.createConnection('broken', false, 'no-encryption', 1, false); + await third.open(); + expect((await third.getVersion()).version).toBe(1); + const rows = await third.query('SELECT * FROM notes'); + expect(rows.values).toEqual([{ id: 1, body: 'precious' }]); + await sqlite.closeConnection('broken', false); + }); + + test(`${label}: an upgrade entry with no statements is rejected`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('empty', [{ toVersion: 1, statements: [] }]); + const db = await sqlite.createConnection('empty', false, 'no-encryption', 1, false); + await expect(db.open()).rejects.toThrow(/statements not given/i); + await sqlite.closeConnection('empty', false); + }); + + test(`${label}: upgrade statements containing trigger bodies survive splitting`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('triggers', [ + { + toVersion: 1, + statements: [ + 'CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT, seen INTEGER DEFAULT 0);', + `CREATE TRIGGER t_ins AFTER INSERT ON t + BEGIN + UPDATE t SET seen = 1 WHERE id = new.id; + END;`, + ], + }, + ]); + const db = await sqlite.createConnection('triggers', false, 'no-encryption', 1, false); + await db.open(); + await db.run('INSERT INTO t (v) VALUES (?)', ['fires']); + expect((await db.query('SELECT seen FROM t')).values?.[0].seen).toBe(1); + await sqlite.closeConnection('triggers', false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index dee5deb8..b2750cb8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "strict": true, "target": "es2020" }, - "files": ["src/index.ts"] + "files": ["src/index.ts", "src/web/worker/worker.ts"] } diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 00000000..adfea473 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,78 @@ +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +/** + * An in-memory fixture endpoint on the dev server. + * + * copyFromAssets and getFromHTTPRequest fetch from inside the worker, which has its own global + * scope, so stubbing `fetch` on the main thread would not reach them. Tests PUT real database + * bytes here and then point the plugin at the resulting URLs, which exercises the actual + * network path (real fetch, real ReadableStream body) without ever leaving localhost. + */ +function fixtureServer() { + const store = new Map(); + return { + name: 'sqlite-web-test-fixtures', + configureServer(server: any) { + server.middlewares.use((req: any, res: any, next: any) => { + const path = (req.url || '').split('?')[0]; + if (!path.startsWith('/__fixture/')) return next(); + if (req.method === 'PUT') { + const chunks: any[] = []; + req.on('data', (chunk: any) => chunks.push(chunk)); + req.on('end', () => { + store.set(path, Buffer.concat(chunks)); + res.statusCode = 204; + res.end(); + }); + return; + } + if (req.method === 'DELETE') { + store.clear(); + res.statusCode = 204; + res.end(); + return; + } + const body = store.get(path); + if (!body) { + res.statusCode = 404; + res.end('no fixture'); + return; + } + res.setHeader('content-type', 'application/octet-stream'); + res.setHeader('content-length', String(body.length)); + res.end(body); + }); + }, + }; +} + +/** + * Real Chromium, not jsdom: OPFS sync access handles and dedicated workers do not exist there, + * and both are load-bearing for this implementation. + * + * browser.fileParallelism stays on. opfs-sahpool is single-owner per origin, which looks like it + * would collide across parallel files, but each file runs in its own Playwright browser context + * with its own storage partition. The consequence is the opposite of the obvious one: any + * multi-context scenario has to be built inside a single file with several workers. + */ +export default defineConfig({ + plugins: [fixtureServer()], + optimizeDeps: { + // sqlite-wasm resolves sqlite3.wasm with `new URL('sqlite3.wasm', import.meta.url)`. + // Pre-bundling moves the module and breaks that resolution. + exclude: ['@sqlite.org/sqlite-wasm'], + }, + test: { + include: ['test/web/**/*.test.ts'], + testTimeout: 60_000, + hookTimeout: 60_000, + browser: { + enabled: true, + provider: playwright(), + headless: true, + screenshotFailures: false, + instances: [{ browser: 'chromium' }], + }, + }, +});